fn lifecycle_filter_names(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
fn lifecycle_node_label_filter(names: &[&str], mode: LabelMatchMode) -> NodeLabelFilter {
NodeLabelFilter {
labels: lifecycle_filter_names(names),
mode,
}
}
fn traverse_depth_two(
engine: &DatabaseEngine,
start: u64,
direction: Direction,
edge_label_filter: Option<&[&str]>,
node_label_filter: Option<&[&str]>,
limit: usize,
at_epoch: Option<i64>,
) -> Vec<TraversalHit> {
engine
.traverse(
start,
2,
&TraverseOptions {
min_depth: 2,
direction,
edge_label_filter: edge_label_filter.map(lifecycle_filter_names),
emit_node_label_filter: node_label_filter
.map(|labels| lifecycle_node_label_filter(labels, LabelMatchMode::Any)),
at_epoch,
decay_lambda: None,
limit: (limit > 0).then_some(limit),
cursor: None,
},
)
.unwrap()
.items
}
fn wait_for_property_index_state(
engine: &DatabaseEngine,
index_id: u64,
expected_state: SecondaryIndexState,
) -> NodePropertyIndexInfo {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(info) = engine
.list_node_property_indexes().unwrap()
.into_iter()
.find(|info| info.index_id == index_id)
{
if info.state == expected_state {
return info;
}
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for property index {} to reach {:?}; current indexes: {:?}",
index_id,
expected_state,
engine.list_node_property_indexes().unwrap()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn wait_for_edge_property_index_state(
engine: &DatabaseEngine,
index_id: u64,
expected_state: SecondaryIndexState,
) -> EdgePropertyIndexInfo {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(info) = engine
.list_edge_property_indexes()
.unwrap()
.into_iter()
.find(|info| info.index_id == index_id)
{
if info.state == expected_state {
return info;
}
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for edge property index {} to reach {:?}; current indexes: {:?}",
index_id,
expected_state,
engine.list_edge_property_indexes().unwrap()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn wait_for_published_property_index_state(
engine: &DatabaseEngine,
index_id: u64,
expected_state: SecondaryIndexState,
) -> SecondaryIndexManifestEntry {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let current_entry = {
let (_guard, published) = engine.runtime.published_snapshot().unwrap();
published
.view
.sources
.secondary_index_entries
.iter()
.find(|entry| entry.index_id == index_id)
.cloned()
};
if let Some(entry) = current_entry.as_ref() {
if entry.state == expected_state {
return entry.clone();
}
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for published property index {} to reach {:?}; core indexes: {:?}; published entry: {:?}",
index_id,
expected_state,
engine.list_node_property_indexes().unwrap(),
current_entry
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn corrupt_sidecar_header_in_place(path: &std::path::Path) {
use std::io::{Seek, SeekFrom, Write};
let payload_offset = component_payload_offset_for_test(path);
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap();
assert!(
file.metadata().unwrap().len() >= payload_offset + 8,
"sidecar must be large enough for an in-place header corruption"
);
file.seek(SeekFrom::Start(payload_offset)).unwrap();
file.write_all(&1_000_000u64.to_le_bytes()).unwrap();
file.sync_all().unwrap();
}
fn component_payload_offset_for_test(path: &std::path::Path) -> u64 {
let data = std::fs::read(path).unwrap();
if data.len() >= crate::segment_components::COMPONENT_IDENTITY_HEADER_LEN
&& data[0..crate::segment_components::COMPONENT_IDENTITY_HEADER_MAGIC.len()]
== crate::segment_components::COMPONENT_IDENTITY_HEADER_MAGIC
{
crate::segment_components::decode_identity_header(&data)
.unwrap()
.payload_offset
} else {
0
}
}
fn corrupt_equality_sidecar_tail_group_order_in_place(path: &std::path::Path, value_hash: u64) {
use std::io::{Seek, SeekFrom, Write};
const SECONDARY_EQ_ENTRY_SIZE: usize = 20;
let data = std::fs::read(path).unwrap();
let payload_offset = component_payload_offset_for_test(path) as usize;
let payload = &data[payload_offset..];
assert!(payload.len() >= 8, "equality sidecar payload missing count");
let count = u64::from_le_bytes(payload[0..8].try_into().unwrap()) as usize;
let index_bytes = 8 + count * SECONDARY_EQ_ENTRY_SIZE;
assert!(
payload.len() >= index_bytes,
"equality sidecar payload missing index"
);
for index in 0..count {
let entry_off = 8 + index * SECONDARY_EQ_ENTRY_SIZE;
let entry_value_hash =
u64::from_le_bytes(payload[entry_off..entry_off + 8].try_into().unwrap());
if entry_value_hash != value_hash {
continue;
}
let group_offset =
u64::from_le_bytes(payload[entry_off + 8..entry_off + 16].try_into().unwrap())
as usize;
let id_count =
u32::from_le_bytes(payload[entry_off + 16..entry_off + 20].try_into().unwrap())
as usize;
assert!(
id_count >= 2,
"target equality sidecar group must have at least two IDs"
);
assert!(
payload.len() >= group_offset + 16,
"target equality sidecar group missing first two IDs"
);
let first = u64::from_le_bytes(payload[group_offset..group_offset + 8].try_into().unwrap());
let second =
u64::from_le_bytes(payload[group_offset + 8..group_offset + 16].try_into().unwrap());
assert!(
first < second,
"test setup expects initially sorted equality postings"
);
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap();
file.seek(SeekFrom::Start((payload_offset + group_offset) as u64))
.unwrap();
file.write_all(&second.to_le_bytes()).unwrap();
file.write_all(&first.to_le_bytes()).unwrap();
file.sync_all().unwrap();
return;
}
panic!("target equality sidecar group hash {value_hash} not found");
}
fn corrupt_range_sidecar_tail_sort_order_in_place(path: &std::path::Path) {
use std::io::{Seek, SeekFrom, Write};
const SECONDARY_RANGE_KEY_BYTES: usize = 24;
const SECONDARY_RANGE_ENTRY_SIZE: usize = SECONDARY_RANGE_KEY_BYTES + 8;
let data = std::fs::read(path).unwrap();
let payload_offset = component_payload_offset_for_test(path) as usize;
let payload = &data[payload_offset..];
assert!(payload.len() >= 8, "range sidecar payload missing count");
let count = u64::from_le_bytes(payload[0..8].try_into().unwrap()) as usize;
assert!(
count >= 3,
"range sidecar test needs at least three entries to corrupt an unqueried tail"
);
let index_bytes = 8 + count * SECONDARY_RANGE_ENTRY_SIZE;
assert!(
payload.len() >= index_bytes,
"range sidecar payload missing fixed entries"
);
let previous_off = 8 + (count - 2) * SECONDARY_RANGE_ENTRY_SIZE;
let tail_off = 8 + (count - 1) * SECONDARY_RANGE_ENTRY_SIZE;
let previous_key = &payload[previous_off..previous_off + SECONDARY_RANGE_KEY_BYTES];
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap();
file.seek(SeekFrom::Start((payload_offset + tail_off) as u64))
.unwrap();
file.write_all(previous_key).unwrap();
file.sync_all().unwrap();
}
fn component_payload_from_bytes_for_test(data: Vec<u8>) -> Vec<u8> {
if data.len() >= crate::segment_components::COMPONENT_IDENTITY_HEADER_LEN
&& data[0..crate::segment_components::COMPONENT_IDENTITY_HEADER_MAGIC.len()]
== crate::segment_components::COMPONENT_IDENTITY_HEADER_MAGIC
{
let header = crate::segment_components::decode_identity_header(&data).unwrap();
let start = header.payload_offset as usize;
let end = start + header.payload_len as usize;
return data[start..end].to_vec();
}
data
}
fn try_read_manifest_component_payload_for_test(
seg_dir: &std::path::Path,
kind: SegmentComponentKind,
) -> Option<Vec<u8>> {
let manifest = read_component_manifest_for_test(seg_dir);
let record = manifest
.components
.iter()
.find(|record| record.kind == kind)?;
match &record.handle {
crate::segment_components::ComponentHandleV1::ExternalFile { relative_path, .. } => {
let data = std::fs::read(seg_dir.join(relative_path)).unwrap();
Some(component_payload_from_bytes_for_test(data))
}
crate::segment_components::ComponentHandleV1::PackedRange { offset, len, .. } => {
let core = std::fs::read(seg_dir.join(crate::segment_components::PACKED_CORE_FILENAME))
.unwrap();
let core_payload = component_payload_from_bytes_for_test(core);
let start = *offset as usize;
let end = start + *len as usize;
Some(core_payload[start..end].to_vec())
}
}
}
fn read_external_component_payload_for_test(path: &std::path::Path) -> Vec<u8> {
let data = std::fs::read(path)
.unwrap_or_else(|error| panic!("failed to read {}: {}", path.display(), error));
component_payload_from_bytes_for_test(data)
}
fn read_manifest_component_payload_for_test(
seg_dir: &std::path::Path,
kind: SegmentComponentKind,
) -> Vec<u8> {
let missing_kind = kind.clone();
try_read_manifest_component_payload_for_test(seg_dir, kind)
.unwrap_or_else(|| panic!("missing component payload {:?}", missing_kind))
}
fn read_component_manifest_for_test(
seg_dir: &std::path::Path,
) -> crate::segment_components::SegmentComponentManifestV1 {
let data =
std::fs::read(seg_dir.join(crate::segment_components::SEGMENT_COMPONENT_MANIFEST_FILENAME))
.unwrap();
crate::segment_components::decode_manifest_envelope(&data).unwrap()
}
#[derive(Debug, Clone)]
struct PackedCoreSnapshot {
bytes: Vec<u8>,
len: u64,
modified: std::time::SystemTime,
}
fn packed_core_snapshot_for_test(seg_dir: &std::path::Path) -> PackedCoreSnapshot {
let path = seg_dir.join(crate::segment_components::PACKED_CORE_FILENAME);
let metadata = std::fs::metadata(&path)
.unwrap_or_else(|error| panic!("failed to stat {}: {}", path.display(), error));
let bytes = std::fs::read(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {}", path.display(), error));
assert_eq!(
metadata.len(),
bytes.len() as u64,
"segment.core metadata length should match bytes read"
);
PackedCoreSnapshot {
bytes,
len: metadata.len(),
modified: metadata.modified().expect("segment.core modified time"),
}
}
fn assert_packed_core_unchanged_for_test(
seg_dir: &std::path::Path,
before: &PackedCoreSnapshot,
label: &str,
) {
let after = packed_core_snapshot_for_test(seg_dir);
assert_eq!(after.len, before.len, "{label} changed segment.core length");
assert_eq!(
after.modified, before.modified,
"{label} changed segment.core mtime"
);
assert_eq!(
after.bytes, before.bytes,
"{label} changed segment.core bytes"
);
}
fn assert_no_legacy_property_components(seg_dir: &std::path::Path) {
let manifest = read_component_manifest_for_test(seg_dir);
assert!(manifest.components.iter().all(|record| {
!matches!(
record.kind,
SegmentComponentKind::LegacyNodePropertyIndex
| SegmentComponentKind::NodePropertyHashMetadata
)
}));
}
fn read_u64_le_for_test(data: &[u8], offset: usize, label: &str) -> u64 {
let end = offset
.checked_add(8)
.unwrap_or_else(|| panic!("{label} u64 offset overflow at {offset}"));
let bytes = data
.get(offset..end)
.unwrap_or_else(|| panic!("{label} missing u64 at [{offset}, {end})"));
u64::from_le_bytes(bytes.try_into().unwrap())
}
fn read_u32_le_for_test(data: &[u8], offset: usize, label: &str) -> u32 {
let end = offset
.checked_add(4)
.unwrap_or_else(|| panic!("{label} u32 offset overflow at {offset}"));
let bytes = data
.get(offset..end)
.unwrap_or_else(|| panic!("{label} missing u32 at [{offset}, {end})"));
u32::from_le_bytes(bytes.try_into().unwrap())
}
fn read_u16_le_for_test(data: &[u8], offset: usize, label: &str) -> u16 {
let end = offset
.checked_add(2)
.unwrap_or_else(|| panic!("{label} u16 offset overflow at {offset}"));
let bytes = data
.get(offset..end)
.unwrap_or_else(|| panic!("{label} missing u16 at [{offset}, {end})"));
u16::from_le_bytes(bytes.try_into().unwrap())
}
fn wait_for_pending_secondary_index_followup_count(
engine: &DatabaseEngine,
expected_count: usize,
) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let current_count = engine.pending_secondary_index_followup_count_for_test();
if current_count == expected_count {
return;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for pending secondary-index followup count {}; current count: {}",
expected_count,
current_count
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn wait_for_path_absent(path: &std::path::Path) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while path.exists() {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for {} to be removed",
path.display()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn test_open_creates_new_db() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.node_count().unwrap(), 0);
assert_eq!(engine.edge_count().unwrap(), 0);
assert!(db_path.exists());
assert!(db_path.join("manifest.current").exists());
engine.close().unwrap();
}
#[test]
fn test_open_nonexistent_without_create() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("nope");
let opts = DbOptions {
create_if_missing: false,
..DbOptions::default()
};
let result = DatabaseEngine::open(&db_path, &opts);
assert!(result.is_err());
}
#[test]
fn test_open_persists_and_validates_dense_vector_manifest() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("vector_db");
let dense_config = DenseVectorConfig {
dimension: 384,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
};
let opts = DbOptions {
dense_vector: Some(dense_config.clone()),
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
assert_eq!(engine.manifest().unwrap().dense_vector.as_ref(), Some(&dense_config));
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
assert_eq!(engine.manifest().unwrap().dense_vector.as_ref(), Some(&dense_config));
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.manifest().unwrap().dense_vector.as_ref(), Some(&dense_config));
engine.close().unwrap();
}
let mismatched = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 256,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
match DatabaseEngine::open(&db_path, &mismatched) {
Err(EngineError::InvalidOperation(_)) => {}
Err(other) => panic!("expected InvalidOperation, got {}", other),
Ok(_) => panic!("expected mismatched dense vector config to fail"),
}
}
#[test]
fn test_open_canonicalizes_vector_payloads_from_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("vector_db");
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine.close().unwrap();
}
let mut writer = WalWriter::open_generation(&db_path, 0).unwrap();
writer
.append(
&WalOp::EnsureNodeLabel {
label: "Person".to_string(),
label_id: 1,
},
1,
)
.unwrap();
writer
.append(
&WalOp::UpsertNode(NodeRecord {
id: 7,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "manual-vector".to_string(),
props: BTreeMap::new(),
created_at: 100,
updated_at: 101,
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
sparse_vector: Some(vec![(4, 0.25), (2, 2.0), (4, 0.5), (7, 0.0)]),
last_write_seq: 0,
}),
2,
)
.unwrap();
writer.sync().unwrap();
drop(writer);
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let node = engine.get_node(7).unwrap().unwrap();
assert_eq!(node.dense_vector, Some(vec![0.1, 0.2, 0.3]));
assert_eq!(node.sparse_vector, Some(vec![(2, 2.0), (4, 0.75)]));
engine.close().unwrap();
}
#[test]
fn test_open_tolerates_compacted_dense_segment_missing_hnsw_graph() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap().unwrap();
let seg_id = engine.segments_for_test()[0].segment_id;
engine.close().unwrap();
drop(engine);
let seg_dir = crate::segment_writer::segment_dir(dir.path(), seg_id);
std::fs::remove_file(seg_dir.join(crate::dense_hnsw::DENSE_HNSW_GRAPH_FILENAME)).unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert!(reopened.get_node_by_key("Person", "b").unwrap().is_some());
reopened.close().unwrap();
}
#[test]
fn test_open_rejects_compacted_dense_segment_truncated_vector_blob() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.4, 0.5, 0.6]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap().unwrap();
let seg_id = engine.segments_for_test()[0].segment_id;
engine.close().unwrap();
drop(engine);
let seg_dir = crate::segment_writer::segment_dir(dir.path(), seg_id);
let manifest = read_component_manifest_for_test(&seg_dir);
let record = manifest
.components
.iter()
.find(|record| record.kind == SegmentComponentKind::NodeDenseVectorBlob)
.expect("dense vector blob component should exist");
let crate::segment_components::ComponentHandleV1::PackedRange { offset, len, .. } =
&record.handle
else {
panic!("compacted dense vector source truth should be packed");
};
assert!(*len >= 4);
let core_path = seg_dir.join(crate::segment_components::PACKED_CORE_FILENAME);
let truncated_payload_len = *offset + *len - 4;
let truncated_file_len = crate::segment_components::COMPONENT_IDENTITY_HEADER_LEN as u64
+ truncated_payload_len;
assert!(truncated_file_len < std::fs::metadata(&core_path).unwrap().len());
std::fs::OpenOptions::new()
.write(true)
.open(&core_path)
.unwrap()
.set_len(truncated_file_len)
.unwrap();
match DatabaseEngine::open(dir.path(), &opts) {
Err(EngineError::CorruptRecord(_)) => {}
Err(other) => panic!("expected CorruptRecord, got {}", other),
Ok(_) => panic!("expected reopen to fail for truncated dense vector blob"),
}
}
#[test]
fn test_open_tolerates_standard_compacted_dense_segment_missing_hnsw_graph() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let node_id = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.75,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert_eq!(
node_id,
engine.get_node_by_key("Person", "shared").unwrap().unwrap().id
);
assert_eq!(compaction_path_for(&engine), CompactionPath::UnifiedV3);
engine.compact().unwrap().unwrap();
let seg_id = engine.segments_for_test()[0].segment_id;
engine.close().unwrap();
drop(engine);
let seg_dir = crate::segment_writer::segment_dir(dir.path(), seg_id);
std::fs::remove_file(seg_dir.join(crate::dense_hnsw::DENSE_HNSW_GRAPH_FILENAME)).unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(
node_id,
reopened.get_node_by_key("Person", "shared").unwrap().unwrap().id
);
reopened.close().unwrap();
}
#[test]
fn test_open_rejects_invalid_vector_payloads_from_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("vector_db");
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine.close().unwrap();
}
let mut writer = WalWriter::open_generation(&db_path, 0).unwrap();
writer
.append(
&WalOp::UpsertNode(NodeRecord {
id: 8,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "bad-vector".to_string(),
props: BTreeMap::new(),
created_at: 100,
updated_at: 101,
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
sparse_vector: None,
last_write_seq: 0,
}),
1,
)
.unwrap();
writer.sync().unwrap();
drop(writer);
match DatabaseEngine::open(&db_path, &opts) {
Err(EngineError::CorruptWal(message)) => {
assert!(message.contains("invalid vector payload"));
}
Ok(_) => panic!("expected invalid vector WAL replay to fail"),
Err(other) => panic!("expected CorruptWal, got {}", other),
}
}
#[test]
fn test_open_rejects_malformed_vector_frame_from_wal_replay() {
use std::io::Write;
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("vector_db");
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine.close().unwrap();
}
let walop_bytes = crate::encoding::encode_wal_op(&WalOp::UpsertNode(NodeRecord {
id: 9,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "bad-frame".to_string(),
props: BTreeMap::new(),
created_at: 100,
updated_at: 101,
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
sparse_vector: None,
last_write_seq: 0,
}))
.unwrap();
let mut payload = Vec::new();
payload.extend_from_slice(&1u64.to_le_bytes()); payload.extend_from_slice(&walop_bytes);
payload.push(0xFF); let crc = crc32fast::hash(&payload);
let len = payload.len() as u32;
let wal_path = wal_generation_path(&db_path, 0);
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&wal_path)
.unwrap();
file.write_all(&len.to_le_bytes()).unwrap();
file.write_all(&crc.to_le_bytes()).unwrap();
file.write_all(&payload).unwrap();
file.flush().unwrap();
drop(file);
match DatabaseEngine::open(&db_path, &opts) {
Err(EngineError::CorruptWal(message)) => {
assert!(message.contains("failed to decode WAL record"));
}
Ok(_) => panic!("expected malformed vector frame WAL replay to fail"),
Err(other) => panic!("expected CorruptWal, got {}", other),
}
}
#[test]
fn test_write_and_read_back() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(1, "alice")))
.unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(2, "bob")))
.unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertEdge(make_edge(1, 1, 2)))
.unwrap();
assert_eq!(engine.node_count().unwrap(), 2);
assert_eq!(engine.edge_count().unwrap(), 1);
let alice = engine.get_node(1).unwrap().unwrap();
assert_eq!(alice.key, "alice");
let edge = engine.get_edge(1).unwrap().unwrap();
assert_eq!(edge.from, 1);
assert_eq!(edge.to, 2);
engine.close().unwrap();
}
#[test]
fn test_delete_operations() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(1, "alice")))
.unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertEdge(make_edge(1, 1, 1)))
.unwrap();
assert!(engine.get_node(1).unwrap().is_some());
assert!(engine.get_edge(1).unwrap().is_some());
write_internal_wal_op(&engine, &WalOp::DeleteNode {
id: 1,
deleted_at: 9999,
})
.unwrap();
write_internal_wal_op(&engine, &WalOp::DeleteEdge {
id: 1,
deleted_at: 9999,
})
.unwrap();
assert!(engine.get_node(1).unwrap().is_none());
assert!(engine.get_edge(1).unwrap().is_none());
assert_eq!(engine.node_count().unwrap(), 0);
assert_eq!(engine.edge_count().unwrap(), 0);
engine.close().unwrap();
}
#[test]
fn test_close_and_reopen_recovers_state() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 1..=10 {
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(i, &format!("node:{}", i))))
.unwrap();
}
for i in 1..=5 {
write_internal_wal_op(&engine, &WalOp::UpsertEdge(make_edge(i, i, i + 5)))
.unwrap();
}
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 1..=10 {
assert!(
engine.get_node(i).unwrap().is_some(),
"node {} missing after close+reopen",
i
);
}
for i in 1..=5 {
assert!(
engine.get_edge(i).unwrap().is_some(),
"edge {} missing after close+reopen",
i
);
}
let node5 = engine.get_node(5).unwrap().unwrap();
assert_eq!(node5.key, "node:5");
let edge3 = engine.get_edge(3).unwrap().unwrap();
assert_eq!(edge3.from, 3);
assert_eq!(edge3.to, 8);
engine.close().unwrap();
}
}
#[test]
fn test_manifest_id_counters_survive_restart() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(42, "high_id")))
.unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertEdge(make_edge(99, 42, 42)))
.unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(engine.next_node_id().unwrap() >= 43);
assert!(engine.next_edge_id().unwrap() >= 100);
engine.close().unwrap();
}
}
#[test]
fn test_wal_replay_with_deletes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(1, "will_delete")))
.unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(2, "will_keep")))
.unwrap();
write_internal_wal_op(&engine, &WalOp::DeleteNode {
id: 1,
deleted_at: 5000,
})
.unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(engine.get_node(1).unwrap().is_none());
assert!(engine.get_node(2).unwrap().is_some());
assert_eq!(engine.get_nodes_by_labels("Person").unwrap().len(), 1);
engine.close().unwrap();
}
}
#[test]
fn test_write_op_batch() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let ops: Vec<WalOp> = (1..=50)
.map(|i| WalOp::UpsertNode(make_node(i, &format!("batch:{}", i))))
.collect();
write_internal_wal_op_batch(&engine, &ops).unwrap();
assert_eq!(engine.node_count().unwrap(), 50);
assert_eq!(engine.get_node(25).unwrap().unwrap().key, "batch:25");
engine.close().unwrap();
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.get_nodes_by_labels("Person").unwrap().len(), 50);
engine.close().unwrap();
}
#[test]
fn test_write_op_batch_survives_restart() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut ops = Vec::new();
for i in 1..=20 {
ops.push(WalOp::UpsertNode(make_node(i, &format!("n:{}", i))));
}
for i in 1..=10 {
ops.push(WalOp::UpsertEdge(make_edge(i, i, i + 10)));
}
write_internal_wal_op_batch(&engine, &ops).unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.get_nodes_by_labels("Person").unwrap().len(), 20);
for i in 1..=10 {
assert!(engine.get_edge(i).unwrap().is_some(), "edge {} missing", i);
}
engine.close().unwrap();
}
}
#[test]
fn test_write_op_batch_normalizes_node_vectors() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let ops = vec![WalOp::UpsertNode(NodeRecord {
id: 1,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "vector-batch".to_string(),
props: BTreeMap::new(),
created_at: 100,
updated_at: 101,
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2]),
sparse_vector: Some(vec![(8, 0.0), (3, 1.0), (3, 2.0)]),
last_write_seq: 0,
})];
write_internal_wal_op_batch(&engine, &ops).unwrap();
let node = engine.get_node(1).unwrap().unwrap();
assert_eq!(node.dense_vector, Some(vec![0.1, 0.2]));
assert_eq!(node.sparse_vector, Some(vec![(3, 3.0)]));
engine.close().unwrap();
}
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let node = engine.get_node(1).unwrap().unwrap();
assert_eq!(node.dense_vector, Some(vec![0.1, 0.2]));
assert_eq!(node.sparse_vector, Some(vec![(3, 3.0)]));
engine.close().unwrap();
}
#[test]
fn test_upsert_overwrites_on_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(1, "v1")))
.unwrap();
let mut updated = make_node(1, "v2");
updated.weight = 0.99;
write_internal_wal_op(&engine, &WalOp::UpsertNode(updated)).unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node = engine.get_node(1).unwrap().unwrap();
assert_eq!(node.key, "v2");
assert!((node.weight - 0.99).abs() < f32::EPSILON);
assert_eq!(engine.get_nodes_by_labels("Person").unwrap().len(), 1);
engine.close().unwrap();
}
}
#[test]
fn test_flush_creates_segment() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
assert_eq!(engine.segment_count().unwrap(), 0);
let info = engine.flush().unwrap();
assert!(info.is_some());
assert_eq!(engine.segment_count().unwrap(), 1);
assert_eq!(engine.node_count().unwrap(), 2);
engine.close().unwrap();
}
#[test]
fn test_flush_empty_memtable_is_noop() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = engine.flush().unwrap();
assert!(info.is_none());
assert_eq!(engine.segment_count().unwrap(), 0);
engine.close().unwrap();
}
#[test]
fn test_data_readable_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let alice = engine.get_node(a).unwrap().unwrap();
assert_eq!(alice.key, "alice");
let bob = engine.get_node(b).unwrap().unwrap();
assert_eq!(bob.key, "bob");
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.from, a);
assert_eq!(edge.to, b);
engine.close().unwrap();
}
#[test]
fn test_neighbors_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(
a,
c,
"REPORTS_TO",
UpsertEdgeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 2);
let ids: Vec<u64> = out.iter().map(|e| e.node_id).collect();
assert!(ids.contains(&b));
assert!(ids.contains(&c));
let labeled = engine
.neighbors(
a,
&NeighborOptions {
edge_label_filter: Some(vec!["KNOWS".to_string()]),
..Default::default()
},
)
.unwrap();
assert_eq!(labeled.len(), 1);
assert_eq!(labeled[0].node_id, b);
engine.close().unwrap();
}
#[test]
fn test_traverse_depth_two_reproduces_basic_two_hop() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(c, d, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let hop2 = traverse_depth_two(&engine, a, Direction::Outgoing, None, None, 0, None);
assert_eq!(hop2.len(), 1);
assert_eq!(hop2[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_traverse_depth_two_excludes_origin_and_hop1() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap(); engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let hop2 = traverse_depth_two(&engine, a, Direction::Outgoing, None, None, 0, None);
assert_eq!(hop2.len(), 1);
assert_eq!(hop2[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_traverse_depth_two_respects_limit() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let e = engine
.upsert_node(
"Person",
"e",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let f = engine
.upsert_node(
"Person",
"f",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, d, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, e, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(c, f, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let all = traverse_depth_two(&engine, a, Direction::Outgoing, None, None, 0, None);
assert_eq!(all.len(), 3);
let limited = traverse_depth_two(&engine, a, Direction::Outgoing, None, None, 2, None);
assert_eq!(limited.len(), 2);
engine.close().unwrap();
}
#[test]
fn test_traverse_depth_two_respects_edge_label_filter() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, d, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
let hop2 = traverse_depth_two(
&engine,
a,
Direction::Outgoing,
Some(&["RELATES_TO"]),
None,
0,
None,
);
assert_eq!(hop2.len(), 1);
assert_eq!(hop2[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_traverse_depth_two_incoming() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(c, d, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let hop2 = traverse_depth_two(&engine, d, Direction::Incoming, None, None, 0, None);
assert_eq!(hop2.len(), 1);
assert_eq!(hop2[0].node_id, b);
engine.close().unwrap();
}
#[test]
fn test_traverse_depth_two_nonexistent_or_hidden_start() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hop2 = traverse_depth_two(&engine, 999, Direction::Outgoing, None, None, 0, None);
assert!(hop2.is_empty());
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.delete_node(a).unwrap();
let hop2 = traverse_depth_two(&engine, a, Direction::Outgoing, None, None, 0, None);
assert!(hop2.is_empty());
engine.close().unwrap();
}
#[test]
fn test_cross_source_reads_memtable_plus_segment() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let c = engine
.upsert_node(
"Person",
"charlie",
UpsertNodeOptions {
weight: 0.7,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
a,
c,
"KNOWS",
UpsertEdgeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
assert!(engine.get_node(a).unwrap().is_some()); assert!(engine.get_node(c).unwrap().is_some());
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 2);
let ids: Vec<u64> = out.iter().map(|e| e.node_id).collect();
assert!(ids.contains(&b));
assert!(ids.contains(&c));
engine.close().unwrap();
}
#[test]
fn test_upsert_dedup_across_flush_boundary() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id1 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut props = BTreeMap::new();
props.insert("version".to_string(), PropValue::Int(2));
let id2 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props,
weight: 0.9,
..Default::default()
},
)
.unwrap();
assert_eq!(id1, id2);
let node = engine.get_node(id1).unwrap().unwrap();
assert_eq!(node.props.get("version"), Some(&PropValue::Int(2)));
assert!((node.weight - 0.9).abs() < f32::EPSILON);
engine.close().unwrap();
}
#[test]
fn test_tombstone_hides_segment_data() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(b).unwrap();
assert!(engine.get_node(b).unwrap().is_none());
engine.delete_edge(eid).unwrap();
assert!(engine.get_edge(eid).unwrap().is_none());
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert!(out.is_empty());
engine.close().unwrap();
}
#[test]
fn test_tombstone_survives_second_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(a).unwrap();
engine.flush().unwrap();
assert!(engine.get_node(a).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_multiple_flushes_accumulate_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut ids = Vec::new();
for i in 0..3 {
let id = engine
.upsert_node(
"Person",
&format!("batch:{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
ids.push(id);
engine.flush().unwrap();
}
assert_eq!(engine.segment_count().unwrap(), 3);
for (i, &id) in ids.iter().enumerate() {
let node = engine.get_node(id).unwrap().unwrap();
assert_eq!(node.key, format!("batch:{}", i));
}
engine.close().unwrap();
}
#[test]
fn test_flush_updates_manifest() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let manifest = engine.manifest().unwrap();
assert_eq!(manifest.segments.len(), 1);
assert_eq!(manifest.segments[0].id, 1);
engine.close().unwrap();
}
#[test]
fn test_id_counters_survive_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..5 {
engine
.upsert_node(
"Person",
&format!("n:{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
let next_before = engine.next_node_id().unwrap();
engine.flush().unwrap();
let new_id = engine
.upsert_node(
"Person",
"after_flush",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
assert!(new_id >= next_before);
engine.close().unwrap();
}
#[test]
fn test_segment_data_survives_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let a;
let b;
let eid;
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.segment_count().unwrap(), 1);
assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(b).unwrap().is_some());
assert!(engine.get_edge(eid).unwrap().is_some());
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, b);
engine.close().unwrap();
}
}
#[test]
fn test_deleted_edge_excluded_from_segment_neighbors() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let e1 = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_edge(e1).unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_upsert_after_delete_across_flush_gets_new_id() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id1 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(id1).unwrap();
assert!(engine.get_node(id1).unwrap().is_none());
let id2 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.7,
..Default::default()
},
)
.unwrap();
assert_ne!(id1, id2);
assert!(engine.get_node(id2).unwrap().is_some());
assert!(engine.get_node(id1).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_auto_flush_triggers_on_threshold() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
memtable_flush_threshold: 256, ..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
assert_eq!(engine.segment_count().unwrap(), 0);
let mut ids = Vec::new();
for i in 0..20 {
let id = engine
.upsert_node(
"Person",
&format!("node:{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
ids.push(id);
}
engine.flush().unwrap();
assert!(engine.segment_count().unwrap() >= 1);
for (i, &id) in ids.iter().enumerate() {
let node = engine.get_node(id).unwrap().unwrap();
assert_eq!(node.key, format!("node:{}", i));
}
engine.close().unwrap();
}
#[test]
fn test_auto_flush_disabled_when_zero() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
memtable_flush_threshold: 0, ..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..100 {
engine
.upsert_node(
"Person",
&format!("node:{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
assert_eq!(engine.segment_count().unwrap(), 0);
assert_eq!(engine.node_count().unwrap(), 100);
engine.close().unwrap();
}
#[test]
fn test_compact_requires_two_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(engine.compact().unwrap().is_none());
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.segment_count().unwrap(), 1);
assert!(engine.compact().unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_compact_merges_two_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.segment_count().unwrap(), 2);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
assert_eq!(stats.nodes_kept, 2);
assert_eq!(stats.nodes_removed, 0);
assert_eq!(engine.segment_count().unwrap(), 1);
assert_eq!(engine.get_node(a).unwrap().unwrap().key, "alice");
assert_eq!(engine.get_node(b).unwrap().unwrap().key, "bob");
engine.close().unwrap();
}
#[test]
fn test_compact_applies_tombstones() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(b).unwrap();
engine.delete_edge(eid).unwrap();
engine.flush().unwrap();
assert_eq!(engine.segment_count().unwrap(), 2);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
assert_eq!(stats.nodes_kept, 1); assert_eq!(stats.nodes_removed, 1); assert_eq!(stats.edges_kept, 0);
assert_eq!(stats.edges_removed, 1);
assert_eq!(engine.segment_count().unwrap(), 1);
assert!(stats.output_segment_id > 0);
assert!(stats.duration_ms < 30_000);
assert_eq!(engine.segment_tombstone_node_count().unwrap(), 0);
assert_eq!(engine.segment_tombstone_edge_count().unwrap(), 0);
assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(b).unwrap().is_none());
assert!(engine.get_edge(eid).unwrap().is_none());
assert!(engine
.neighbors(a, &NeighborOptions::default())
.unwrap()
.is_empty());
engine.close().unwrap();
}
#[test]
fn test_compact_node_last_write_wins() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props_v1 = BTreeMap::new();
props_v1.insert("version".to_string(), PropValue::Int(1));
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: props_v1,
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut props_v2 = BTreeMap::new();
props_v2.insert("version".to_string(), PropValue::Int(2));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: props_v2,
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.segment_count().unwrap(), 2);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 1);
assert_eq!(stats.nodes_removed, 1);
let node = engine.get_node(a).unwrap().unwrap();
assert_eq!(node.props.get("version"), Some(&PropValue::Int(2)));
assert!((node.weight - 0.9).abs() < f32::EPSILON);
engine.close().unwrap();
}
#[test]
fn test_compact_preserves_neighbors() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
a,
c,
"REPORTS_TO",
UpsertEdgeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 2);
let ids: Vec<u64> = out.iter().map(|e| e.node_id).collect();
assert!(ids.contains(&b));
assert!(ids.contains(&c));
let labeled = engine
.neighbors(
a,
&NeighborOptions {
edge_label_filter: Some(vec!["KNOWS".to_string()]),
..Default::default()
},
)
.unwrap();
assert_eq!(labeled.len(), 1);
assert_eq!(labeled[0].node_id, b);
engine.close().unwrap();
}
#[test]
fn test_compact_cleans_up_old_segment_dirs() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let seg_dir = db_path.join("segments");
assert!(seg_dir.join("seg_0001").exists());
assert!(seg_dir.join("seg_0002").exists());
engine.compact().unwrap();
assert!(!seg_dir.join("seg_0001").exists());
assert!(!seg_dir.join("seg_0002").exists());
assert!(seg_dir.join("seg_0003").exists());
engine.close().unwrap();
}
#[test]
fn test_compact_updates_manifest() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.manifest().unwrap().segments.len(), 2);
engine.compact().unwrap();
let manifest = engine.manifest().unwrap();
assert_eq!(manifest.segments.len(), 1);
assert_eq!(manifest.segments[0].node_count, 2);
engine.close().unwrap();
}
#[test]
fn test_compact_data_survives_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let a;
let b;
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.segment_count().unwrap(), 1);
assert_eq!(engine.get_node(a).unwrap().unwrap().key, "alice");
assert_eq!(engine.get_node(b).unwrap().unwrap().key, "bob");
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, b);
engine.close().unwrap();
}
}
#[test]
fn test_compact_three_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut all_ids = Vec::new();
for i in 0..3 {
let id = engine
.upsert_node(
"Person",
&format!("n:{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
all_ids.push(id);
engine.flush().unwrap();
}
assert_eq!(engine.segment_count().unwrap(), 3);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 3);
assert_eq!(stats.nodes_kept, 3);
assert_eq!(engine.segment_count().unwrap(), 1);
for (i, &id) in all_ids.iter().enumerate() {
assert_eq!(
engine.get_node(id).unwrap().unwrap().key,
format!("n:{}", i)
);
}
engine.close().unwrap();
}
#[test]
fn test_compact_with_unflushed_tombstone() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.6,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"charlie",
UpsertNodeOptions {
weight: 0.7,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(b).unwrap();
assert_eq!(engine.segment_count().unwrap(), 2);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 3); assert_eq!(stats.nodes_kept, 2); assert_eq!(stats.nodes_removed, 1);
assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(b).unwrap().is_none());
assert_eq!(engine.segment_count().unwrap(), 1);
engine.close().unwrap();
}
#[test]
fn test_compact_with_unflushed_update() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props_v1 = BTreeMap::new();
props_v1.insert("v".to_string(), PropValue::Int(1));
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: props_v1,
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut props_v2 = BTreeMap::new();
props_v2.insert("v".to_string(), PropValue::Int(2));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: props_v2,
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.compact().unwrap();
let node = engine.get_node(a).unwrap().unwrap();
assert_eq!(node.props.get("v"), Some(&PropValue::Int(2)));
assert!((node.weight - 0.9).abs() < f32::EPSILON);
engine.close().unwrap();
}
#[test]
fn test_compact_removes_dangling_edges_after_node_delete() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let e_ab = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let e_bc = engine
.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(b).unwrap();
engine.flush().unwrap();
assert!(engine
.neighbors(a, &NeighborOptions::default())
.unwrap()
.is_empty());
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 2); assert_eq!(stats.nodes_removed, 1); assert_eq!(stats.edges_kept, 0); assert_eq!(stats.edges_removed, 2);
assert!(engine.get_edge(e_ab).unwrap().is_none());
assert!(engine.get_edge(e_bc).unwrap().is_none());
assert!(engine
.neighbors(a, &NeighborOptions::default())
.unwrap()
.is_empty());
assert!(engine
.neighbors(
c,
&NeighborOptions {
direction: Direction::Incoming,
..Default::default()
}
)
.unwrap()
.is_empty());
engine.close().unwrap();
}
#[test]
fn test_orphan_segment_does_not_reuse_id() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
{
let engine = DatabaseEngine::open(
&db_path,
&DbOptions {
create_if_missing: true,
..Default::default()
},
)
.unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.close().unwrap();
}
let orphan_dir = db_path.join("segments").join("seg_0099");
std::fs::create_dir_all(&orphan_dir).unwrap();
std::fs::write(
orphan_dir.join(crate::segment_components::PACKED_CORE_FILENAME),
[0u8; 0],
)
.unwrap();
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let max_manifest_seg = engine
.manifest().unwrap()
.segments
.iter()
.map(|s| s.id)
.max()
.unwrap();
assert!(
max_manifest_seg >= 100,
"next segment should skip past orphan seg_0099, got seg ID {}",
max_manifest_seg
);
engine.close().unwrap();
}
}
#[test]
fn test_scan_max_segment_id_no_segments_dir() {
let dir = TempDir::new().unwrap();
assert_eq!(scan_max_segment_id(dir.path()), 0);
}
#[test]
fn test_scan_max_segment_id_finds_highest() {
let dir = TempDir::new().unwrap();
let seg_dir = dir.path().join("segments");
std::fs::create_dir_all(&seg_dir).unwrap();
std::fs::create_dir(seg_dir.join("seg_0003")).unwrap();
std::fs::create_dir(seg_dir.join("seg_0010")).unwrap();
std::fs::create_dir(seg_dir.join("seg_0007")).unwrap();
std::fs::create_dir(seg_dir.join("tmp_work")).unwrap();
std::fs::write(seg_dir.join("some_file.txt"), b"hi").unwrap();
assert_eq!(scan_max_segment_id(dir.path()), 10);
}
#[test]
fn test_map_props_roundtrip_memtable_and_segment() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut props = BTreeMap::new();
let mut nested = BTreeMap::new();
nested.insert("deep_key".to_string(), PropValue::Int(99));
nested.insert("flag".to_string(), PropValue::Bool(true));
props.insert("metadata".to_string(), PropValue::Map(nested));
props.insert("name".to_string(), PropValue::String("test".into()));
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id = engine
.upsert_node(
"Person",
"map_node",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
let node = engine.get_node(id).unwrap().unwrap();
assert_eq!(node.props, props);
engine.flush().unwrap();
let node2 = engine.get_node(id).unwrap().unwrap();
assert_eq!(node2.props, props);
engine.close().unwrap();
let engine2 = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node3 = engine2.get_node(id).unwrap().unwrap();
assert_eq!(node3.props, props);
engine2.close().unwrap();
}
fn compaction_path_for(engine: &DatabaseEngine) -> CompactionPath {
select_compaction_path(
&engine.segments_for_test(),
engine.segments_for_test().iter().any(|s| s.has_tombstones()),
!engine.manifest().unwrap().prune_policies.is_empty(),
)
}
fn install_noop_prune_policy(engine: &mut DatabaseEngine) {
engine
.set_prune_policy(
"noop-fast-merge-blocker",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.0),
label: Some("SpecialNode1024".to_string()),
},
)
.unwrap();
}
fn build_clean_compaction_fixture(engine: &mut DatabaseEngine) -> (Vec<u64>, Vec<u64>, Vec<u64>) {
let mut all_node_ids = Vec::new();
let mut all_edge_ids = Vec::new();
let mut segment_starts = Vec::new();
let mut next_node_id = 1u64;
let mut next_edge_id = 1u64;
for seg in 0..3u64 {
let mut seg_node_ids = Vec::new();
for i in 0..12 {
let mut props = BTreeMap::new();
props.insert("seg".to_string(), PropValue::UInt(seg));
props.insert(
"color".to_string(),
PropValue::String(if i % 2 == 0 { "red" } else { "blue" }.to_string()),
);
let id = next_node_id;
next_node_id += 1;
let created_at = 1_000 + (seg as i64 * 100) + (i as i64 * 2);
write_internal_wal_op(engine, &WalOp::UpsertNode(NodeRecord {
id,
label_ids: NodeLabelSet::single(1).unwrap(),
key: format!("s{}_n{}", seg, i),
props,
created_at,
updated_at: created_at + 1,
weight: 1.0,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
}))
.unwrap();
seg_node_ids.push(id);
all_node_ids.push(id);
}
segment_starts.push(seg_node_ids[0]);
for i in 0..4 {
let eid = next_edge_id;
next_edge_id += 1;
let created_at = 5_000 + (seg as i64 * 100) + (i as i64 * 2);
write_internal_wal_op(engine, &WalOp::UpsertEdge(EdgeRecord {
id: eid,
from: seg_node_ids[i],
to: seg_node_ids[i + 1],
label_id: 1,
props: BTreeMap::new(),
created_at,
updated_at: created_at + 1,
weight: 1.0,
valid_from: 0,
valid_to: i64::MAX,
last_write_seq: 0,
}))
.unwrap();
all_edge_ids.push(eid);
}
engine.flush().unwrap();
}
(all_node_ids, all_edge_ids, segment_starts)
}
fn build_vector_compaction_and_flush_fixture(
compact_engine: &DatabaseEngine,
flush_engine: &DatabaseEngine,
) -> (Vec<u64>, Vec<u64>) {
let mut compact_node_ids = Vec::new();
let mut flush_node_ids = Vec::new();
let mut compact_edge_ids = Vec::new();
let mut flush_edge_ids = Vec::new();
let mut next_node_id = 1u64;
let mut next_edge_id = 1u64;
for seg in 0..3u64 {
let mut compact_seg_ids = Vec::new();
let mut flush_seg_ids = Vec::new();
for i in 0..6u64 {
let dense_vector = vec![
1.0 + seg as f32 * 0.1,
0.2 + i as f32 * 0.03,
0.4 + seg as f32 * 0.05,
0.6 + i as f32 * 0.02,
];
let sparse_vector = vec![
(seg as u32, 1.0 + i as f32 * 0.1),
(seg as u32 + 10, 0.5 + seg as f32 * 0.05),
];
let mut props = BTreeMap::new();
props.insert("seg".to_string(), PropValue::UInt(seg));
props.insert("slot".to_string(), PropValue::UInt(i));
let node_id = next_node_id;
next_node_id += 1;
let created_at = 10_000 + (seg as i64 * 100) + (i as i64 * 2);
let compact_node = NodeRecord {
id: node_id,
label_ids: NodeLabelSet::single(1).unwrap(),
key: format!("s{}_n{}", seg, i),
props: props.clone(),
created_at,
updated_at: created_at + 1,
weight: 1.0,
dense_vector: Some(dense_vector.clone()),
sparse_vector: Some(sparse_vector.clone()),
last_write_seq: 0,
};
let flush_node = NodeRecord {
props,
dense_vector: Some(dense_vector),
sparse_vector: Some(sparse_vector),
..compact_node.clone()
};
write_internal_wal_op(compact_engine, &WalOp::UpsertNode(compact_node)).unwrap();
write_internal_wal_op(flush_engine, &WalOp::UpsertNode(flush_node)).unwrap();
compact_seg_ids.push(node_id);
flush_seg_ids.push(node_id);
compact_node_ids.push(node_id);
flush_node_ids.push(node_id);
}
for i in 0..3usize {
let edge_id = next_edge_id;
next_edge_id += 1;
let created_at = 20_000 + (seg as i64 * 100) + (i as i64 * 2);
let compact_edge = EdgeRecord {
id: edge_id,
from: compact_seg_ids[i],
to: compact_seg_ids[i + 1],
label_id: 1,
props: BTreeMap::new(),
created_at,
updated_at: created_at + 1,
weight: 0.5 + seg as f32 * 0.1 + i as f32 * 0.05,
valid_from: seg as i64,
valid_to: i64::MAX,
last_write_seq: 0,
};
let flush_edge = EdgeRecord {
from: flush_seg_ids[i],
to: flush_seg_ids[i + 1],
..compact_edge.clone()
};
write_internal_wal_op(compact_engine, &WalOp::UpsertEdge(compact_edge)).unwrap();
write_internal_wal_op(flush_engine, &WalOp::UpsertEdge(flush_edge)).unwrap();
compact_edge_ids.push(edge_id);
flush_edge_ids.push(edge_id);
}
compact_engine.flush().unwrap();
}
flush_engine.flush().unwrap();
assert_eq!(compact_node_ids, flush_node_ids);
assert_eq!(compact_edge_ids, flush_edge_ids);
(compact_node_ids, compact_edge_ids)
}
fn record_payload_spans_for_test(
payload: &[u8],
label: &str,
) -> BTreeMap<u64, (u64, u32)> {
const RECORD_INDEX_ENTRY_SIZE: usize = 16;
assert!(
payload.len() >= 8,
"{label} payload is missing count header"
);
let count = read_u64_le_for_test(payload, 0, label) as usize;
let index_end = 8 + count * RECORD_INDEX_ENTRY_SIZE;
assert!(
index_end <= payload.len(),
"{label} index exceeds payload length: index_end={index_end}, len={}",
payload.len()
);
let mut entries = Vec::with_capacity(count);
let mut last_id = None;
for index in 0..count {
let entry_off = 8 + index * RECORD_INDEX_ENTRY_SIZE;
let id = read_u64_le_for_test(payload, entry_off, label);
let offset = read_u64_le_for_test(payload, entry_off + 8, label);
if let Some(previous_id) = last_id {
assert!(
previous_id < id,
"{label} record ids are not strictly sorted: {previous_id} before {id}"
);
}
assert!(
offset >= index_end as u64,
"{label} record {id} points into index/header: offset={offset}, data_start={index_end}"
);
assert!(
offset <= payload.len() as u64,
"{label} record {id} offset exceeds payload length: offset={offset}, len={}",
payload.len()
);
entries.push((id, offset));
last_id = Some(id);
}
if count == 0 {
assert_eq!(
payload.len(),
index_end,
"{label} empty payload should contain only count header"
);
return BTreeMap::new();
}
let mut by_offset = entries.clone();
by_offset.sort_unstable_by_key(|(_, offset)| *offset);
assert_eq!(
by_offset[0].1, index_end as u64,
"{label} first record should start immediately after the index"
);
let mut spans = BTreeMap::new();
for (index, (id, offset)) in by_offset.iter().copied().enumerate() {
let next_offset = by_offset
.get(index + 1)
.map(|(_, next)| *next)
.unwrap_or(payload.len() as u64);
assert!(
next_offset > offset,
"{label} record {id} has empty or overlapping span: offset={offset}, next={next_offset}"
);
let data_len = u32::try_from(next_offset - offset)
.unwrap_or_else(|_| panic!("{label} record {id} span exceeds u32"));
spans.insert(id, (offset, data_len));
}
spans
}
fn record_payload_ids_for_test(payload: &[u8], label: &str) -> Vec<u64> {
record_payload_spans_for_test(payload, label)
.into_keys()
.collect()
}
fn assert_metadata_offsets_match_record_payload_for_test(
seg_dir: &std::path::Path,
record_kind: SegmentComponentKind,
metadata_kind: SegmentComponentKind,
metadata_entry_size: usize,
label: &str,
) -> Vec<u64> {
let record_payload = read_manifest_component_payload_for_test(seg_dir, record_kind);
let record_spans = record_payload_spans_for_test(&record_payload, label);
let metadata_payload = read_manifest_component_payload_for_test(seg_dir, metadata_kind.clone());
assert!(
metadata_payload.len() >= 8,
"{label} metadata payload missing count header"
);
let count = read_u64_le_for_test(&metadata_payload, 0, label) as usize;
assert_eq!(
count,
record_spans.len(),
"{label} metadata count should match record count"
);
let (fixed_entries_offset, fixed_entry_size, expected_metadata_len) =
if metadata_kind == SegmentComponentKind::NodeMetadata {
assert!(
metadata_payload.len() >= 48,
"{label} node metadata payload missing v10 header"
);
let fixed_entry_size = read_u16_le_for_test(&metadata_payload, 8, label) as usize;
let label_offset_entry_size =
read_u16_le_for_test(&metadata_payload, 10, label) as usize;
assert_eq!(fixed_entry_size, 48, "{label} node metadata fixed row size");
assert_eq!(
label_offset_entry_size, 8,
"{label} node metadata label offset row size"
);
let fixed_entries_offset = read_u64_le_for_test(&metadata_payload, 16, label) as usize;
let label_offsets_offset = read_u64_le_for_test(&metadata_payload, 24, label) as usize;
let label_ids_offset = read_u64_le_for_test(&metadata_payload, 32, label) as usize;
let label_id_count = read_u64_le_for_test(&metadata_payload, 40, label) as usize;
assert_eq!(fixed_entries_offset, 48, "{label} fixed table offset");
assert_eq!(
label_offsets_offset,
fixed_entries_offset + count * fixed_entry_size,
"{label} label offset table offset"
);
assert_eq!(
label_ids_offset,
label_offsets_offset + (count + 1) * label_offset_entry_size,
"{label} label ID region offset"
);
(
fixed_entries_offset,
fixed_entry_size,
label_ids_offset + label_id_count * 4,
)
} else {
(8, metadata_entry_size, 8 + count * metadata_entry_size)
};
assert_eq!(
metadata_payload.len(),
expected_metadata_len,
"{label} metadata payload length mismatch"
);
let mut ids = Vec::with_capacity(count);
let mut last_id = None;
for index in 0..count {
let off = fixed_entries_offset + index * fixed_entry_size;
let id = read_u64_le_for_test(&metadata_payload, off, label);
let metadata_offset = read_u64_le_for_test(&metadata_payload, off + 8, label);
let metadata_len = read_u32_le_for_test(&metadata_payload, off + 16, label);
if let Some(previous_id) = last_id {
assert!(
previous_id < id,
"{label} metadata ids are not strictly sorted: {previous_id} before {id}"
);
}
let (record_offset, record_len) = record_spans
.get(&id)
.copied()
.unwrap_or_else(|| panic!("{label} metadata references missing record {id}"));
assert_eq!(
metadata_offset, record_offset,
"{label} metadata offset mismatch for record {id}"
);
assert_eq!(
metadata_len, record_len,
"{label} metadata length mismatch for record {id}"
);
ids.push(id);
last_id = Some(id);
}
ids
}
fn assert_compacted_record_payloads_match_metadata(seg_dir: &std::path::Path) {
assert_metadata_offsets_match_record_payload_for_test(
seg_dir,
SegmentComponentKind::NodeRecords,
SegmentComponentKind::NodeMetadata,
60,
"node records",
);
assert_metadata_offsets_match_record_payload_for_test(
seg_dir,
SegmentComponentKind::EdgeRecords,
SegmentComponentKind::EdgeMetadata,
80,
"edge records",
);
}
fn assert_compacted_index_files_match(
left: &DatabaseEngine,
right: &DatabaseEngine,
left_db_dir: &std::path::Path,
right_db_dir: &std::path::Path,
) {
let left_dir = segment_dir(left_db_dir, left.segments_for_test()[0].segment_id);
let right_dir = segment_dir(right_db_dir, right.segments_for_test()[0].segment_id);
assert_segment_common_artifacts_match(&left_dir, &right_dir);
}
fn assert_segment_uses_packed_core_layout(seg_dir: &std::path::Path) {
assert!(
seg_dir
.join(crate::segment_components::PACKED_CORE_FILENAME)
.exists(),
"segment.core missing in {}",
seg_dir.display()
);
let manifest = read_component_manifest_for_test(seg_dir);
assert!(matches!(
manifest
.components
.iter()
.find(|record| record.kind == SegmentComponentKind::PackedSegmentContainer)
.expect("missing PackedSegmentContainer")
.handle,
crate::segment_components::ComponentHandleV1::ExternalFile { .. }
));
for kind in [
SegmentComponentKind::NodeRecords,
SegmentComponentKind::EdgeRecords,
SegmentComponentKind::NodeMetadata,
SegmentComponentKind::EdgeMetadata,
SegmentComponentKind::Tombstones,
SegmentComponentKind::KeyIndex,
SegmentComponentKind::NodeLabelIndex,
SegmentComponentKind::EdgeLabelIndex,
SegmentComponentKind::EdgeTripleIndex,
SegmentComponentKind::AdjOutIndex,
SegmentComponentKind::AdjOutPostings,
SegmentComponentKind::AdjInIndex,
SegmentComponentKind::AdjInPostings,
SegmentComponentKind::TimestampIndex,
SegmentComponentKind::EdgeWeightIndex,
SegmentComponentKind::EdgeUpdatedAtIndex,
SegmentComponentKind::EdgeValidFromIndex,
SegmentComponentKind::EdgeValidToIndex,
] {
let record = manifest
.components
.iter()
.find(|record| record.kind == kind)
.unwrap_or_else(|| panic!("missing component {:?}", kind));
assert!(
matches!(
record.handle,
crate::segment_components::ComponentHandleV1::PackedRange { .. }
),
"{:?} should be packed in {}",
kind,
seg_dir.display()
);
}
assert_only_manifested_segment_files(seg_dir, &manifest);
}
fn assert_only_manifested_segment_files(
seg_dir: &std::path::Path,
manifest: &crate::segment_components::SegmentComponentManifestV1,
) {
let mut expected = std::collections::BTreeSet::from([
std::path::PathBuf::from(crate::segment_components::SEGMENT_COMPONENT_MANIFEST_FILENAME),
]);
for record in &manifest.components {
if let crate::segment_components::ComponentHandleV1::ExternalFile {
relative_path, ..
} = &record.handle
{
expected.insert(std::path::PathBuf::from(relative_path));
}
}
for record in &manifest.unknown_optional_components {
if record.wire.handle.handle_tag == 1 {
if let Some(relative_path) = &record.wire.handle.relative_path {
expected.insert(std::path::PathBuf::from(relative_path));
}
}
}
let mut actual = Vec::new();
collect_regular_segment_files(seg_dir, seg_dir, &mut actual);
for relative_path in actual {
assert!(
expected.contains(&relative_path),
"unexpected unmanifested segment file {} in {}",
relative_path.display(),
seg_dir.display()
);
}
}
fn collect_regular_segment_files(
root: &std::path::Path,
dir: &std::path::Path,
files: &mut Vec<std::path::PathBuf>,
) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let file_type = entry.file_type().unwrap();
if file_type.is_dir() {
collect_regular_segment_files(root, &path, files);
} else if file_type.is_file() {
files.push(path.strip_prefix(root).unwrap().to_path_buf());
}
}
}
fn assert_segment_common_artifacts_match(left_dir: &std::path::Path, right_dir: &std::path::Path) {
for kind in [
SegmentComponentKind::KeyIndex,
SegmentComponentKind::NodeLabelIndex,
SegmentComponentKind::EdgeLabelIndex,
SegmentComponentKind::EdgeTripleIndex,
SegmentComponentKind::EdgeWeightIndex,
SegmentComponentKind::EdgeUpdatedAtIndex,
SegmentComponentKind::EdgeValidFromIndex,
SegmentComponentKind::EdgeValidToIndex,
SegmentComponentKind::TimestampIndex,
SegmentComponentKind::AdjOutIndex,
SegmentComponentKind::AdjOutPostings,
SegmentComponentKind::AdjInIndex,
SegmentComponentKind::AdjInPostings,
SegmentComponentKind::Tombstones,
] {
assert_eq!(
read_manifest_component_payload_for_test(left_dir, kind.clone()),
read_manifest_component_payload_for_test(right_dir, kind.clone()),
"{:?} mismatch",
kind
);
}
for kind in [
SegmentComponentKind::NodeVectorMetadata,
SegmentComponentKind::NodeDenseVectorBlob,
SegmentComponentKind::NodeSparseVectorBlob,
SegmentComponentKind::SparsePostingIndex,
SegmentComponentKind::SparsePostings,
] {
let left_payload = try_read_manifest_component_payload_for_test(left_dir, kind.clone());
let right_payload = try_read_manifest_component_payload_for_test(right_dir, kind.clone());
assert_eq!(
left_payload.is_some(),
right_payload.is_some(),
"{:?} presence mismatch",
kind
);
if let (Some(left_payload), Some(right_payload)) = (left_payload, right_payload) {
assert_eq!(
left_payload, right_payload,
"{:?} mismatch",
kind
);
}
}
let left_secondary = left_dir.join(crate::segment_writer::SECONDARY_INDEX_DIRNAME);
let right_secondary = right_dir.join(crate::segment_writer::SECONDARY_INDEX_DIRNAME);
assert_eq!(
left_secondary.exists(),
right_secondary.exists(),
"secondary index directory presence mismatch"
);
if left_secondary.exists() {
let mut left_entries: Vec<_> = std::fs::read_dir(&left_secondary)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
let mut right_entries: Vec<_> = std::fs::read_dir(&right_secondary)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
left_entries.sort_unstable();
right_entries.sort_unstable();
assert_eq!(
left_entries, right_entries,
"secondary index file set mismatch"
);
for name in left_entries {
assert_eq!(
std::fs::read(left_secondary.join(&name)).unwrap(),
std::fs::read(right_secondary.join(&name)).unwrap(),
"secondary index file {:?} mismatch",
name
);
}
}
{
let left_meta_exists = left_dir
.join(crate::dense_hnsw::DENSE_HNSW_META_FILENAME)
.exists();
let right_meta_exists = right_dir
.join(crate::dense_hnsw::DENSE_HNSW_META_FILENAME)
.exists();
assert_eq!(
left_meta_exists, right_meta_exists,
"dense_hnsw_meta.dat presence mismatch"
);
let left_graph_exists = left_dir
.join(crate::dense_hnsw::DENSE_HNSW_GRAPH_FILENAME)
.exists();
let right_graph_exists = right_dir
.join(crate::dense_hnsw::DENSE_HNSW_GRAPH_FILENAME)
.exists();
assert_eq!(
left_graph_exists, right_graph_exists,
"dense_hnsw_graph.dat presence mismatch"
);
if left_meta_exists {
for dir in [left_dir, right_dir] {
let meta = read_external_component_payload_for_test(
&dir.join(crate::dense_hnsw::DENSE_HNSW_META_FILENAME),
);
let graph = read_external_component_payload_for_test(
&dir.join(crate::dense_hnsw::DENSE_HNSW_GRAPH_FILENAME),
);
assert!(meta.len() >= 36, "HNSW meta too short in {}", dir.display());
assert!(!graph.is_empty(), "HNSW graph empty in {}", dir.display());
assert_eq!(&meta[0..4], b"DHNW", "bad HNSW magic in {}", dir.display());
let version = u32::from_le_bytes(meta[4..8].try_into().unwrap());
assert_eq!(version, 1, "bad HNSW version in {}", dir.display());
let point_count = u64::from_le_bytes(meta[8..16].try_into().unwrap());
assert!(point_count > 0, "zero HNSW points in {}", dir.display());
}
}
}
}
fn assert_record_artifacts_match(left_dir: &std::path::Path, right_dir: &std::path::Path) {
for kind in [
SegmentComponentKind::NodeRecords,
SegmentComponentKind::EdgeRecords,
SegmentComponentKind::NodeMetadata,
SegmentComponentKind::EdgeMetadata,
] {
assert_eq!(
read_manifest_component_payload_for_test(left_dir, kind.clone()),
read_manifest_component_payload_for_test(right_dir, kind.clone()),
"{:?} mismatch",
kind
);
}
}
fn assert_segment_metadata_semantics_match(left: &SegmentReader, right: &SegmentReader) {
assert_eq!(left.node_meta_count(), right.node_meta_count());
for index in 0..left.node_meta_count() as usize {
let left_meta = left.node_meta_at(index).unwrap();
let right_meta = right.node_meta_at(index).unwrap();
assert_eq!(
left_meta.node_id, right_meta.node_id,
"node {} id mismatch",
index
);
assert_eq!(
left_meta.data_len, right_meta.data_len,
"node {} data_len mismatch",
index
);
assert_eq!(
left_meta.label_ids, right_meta.label_ids,
"node {} label set mismatch",
index
);
assert_eq!(
left_meta.updated_at, right_meta.updated_at,
"node {} updated_at mismatch",
index
);
assert_eq!(
left_meta.weight.to_bits(),
right_meta.weight.to_bits(),
"node {} weight mismatch",
index
);
assert_eq!(
left_meta.key_len, right_meta.key_len,
"node {} key_len mismatch",
index
);
assert_eq!(
left_meta.last_write_seq, right_meta.last_write_seq,
"node {} last_write_seq mismatch",
index
);
let left_vectors = left.node_vector_meta_at(index).unwrap();
let right_vectors = right.node_vector_meta_at(index).unwrap();
assert_eq!(
(left_vectors.1, left_vectors.3),
(right_vectors.1, right_vectors.3),
"node {} vector length mismatch",
index
);
}
assert_eq!(left.edge_meta_count(), right.edge_meta_count());
for index in 0..left.edge_meta_count() as usize {
let left_meta = left.edge_meta_at(index).unwrap();
let right_meta = right.edge_meta_at(index).unwrap();
assert_eq!(left_meta.0, right_meta.0, "edge {} id mismatch", index);
assert_eq!(
left_meta.2, right_meta.2,
"edge {} data_len mismatch",
index
);
assert_eq!(left_meta.3, right_meta.3, "edge {} from mismatch", index);
assert_eq!(left_meta.4, right_meta.4, "edge {} to mismatch", index);
assert_eq!(left_meta.5, right_meta.5, "edge {} label mismatch", index);
assert_eq!(
left_meta.6, right_meta.6,
"edge {} updated_at mismatch",
index
);
assert_eq!(
left_meta.7.to_bits(),
right_meta.7.to_bits(),
"edge {} weight mismatch",
index
);
assert_eq!(
left_meta.8, right_meta.8,
"edge {} valid_from mismatch",
index
);
assert_eq!(
left_meta.9, right_meta.9,
"edge {} valid_to mismatch",
index
);
assert_eq!(
left_meta.10, right_meta.10,
"edge {} last_write_seq mismatch",
index
);
}
}
fn assert_node_batches_match(left: &[Option<NodeView>], right: &[Option<NodeView>]) {
assert_eq!(left.len(), right.len());
for (idx, (left_node, right_node)) in left.iter().zip(right.iter()).enumerate() {
match (left_node, right_node) {
(Some(left_node), Some(right_node)) => {
assert_eq!(left_node.id, right_node.id, "node {} id mismatch", idx);
assert_eq!(
left_node.labels, right_node.labels,
"node {} labels mismatch",
idx
);
assert_eq!(left_node.key, right_node.key, "node {} key mismatch", idx);
assert_eq!(
left_node.props, right_node.props,
"node {} props mismatch",
idx
);
assert_eq!(
left_node.weight.to_bits(),
right_node.weight.to_bits(),
"node {} weight mismatch",
idx
);
}
(None, None) => {}
_ => panic!("node batch presence mismatch at index {}", idx),
}
}
}
fn assert_edge_batches_match(left: &[Option<EdgeView>], right: &[Option<EdgeView>]) {
assert_eq!(left.len(), right.len());
for (idx, (left_edge, right_edge)) in left.iter().zip(right.iter()).enumerate() {
match (left_edge, right_edge) {
(Some(left_edge), Some(right_edge)) => {
assert_eq!(left_edge.id, right_edge.id, "edge {} id mismatch", idx);
assert_eq!(
left_edge.from, right_edge.from,
"edge {} from mismatch",
idx
);
assert_eq!(left_edge.to, right_edge.to, "edge {} to mismatch", idx);
assert_eq!(
left_edge.label, right_edge.label,
"edge {} label mismatch",
idx
);
assert_eq!(
left_edge.props, right_edge.props,
"edge {} props mismatch",
idx
);
assert_eq!(
left_edge.weight.to_bits(),
right_edge.weight.to_bits(),
"edge {} weight mismatch",
idx
);
assert_eq!(
left_edge.valid_from, right_edge.valid_from,
"edge {} valid_from mismatch",
idx
);
assert_eq!(
left_edge.valid_to, right_edge.valid_to,
"edge {} valid_to mismatch",
idx
);
}
(None, None) => {}
_ => panic!("edge batch presence mismatch at index {}", idx),
}
}
}
#[test]
fn test_segments_non_overlapping_detection() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for seg in 0..3u64 {
for i in 0..10 {
engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
assert!(segments_are_non_overlapping(&engine.segments_for_test()));
engine.close().unwrap();
}
#[test]
fn test_segments_overlapping_detection() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for _seg in 0..3 {
for i in 0..10 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
assert!(!segments_are_non_overlapping(&engine.segments_for_test()));
engine.close().unwrap();
}
#[test]
fn test_fast_merge_eligibility_rules() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let mut engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
build_clean_compaction_fixture(&mut engine);
assert_eq!(compaction_path_for(&engine), CompactionPath::FastMerge);
install_noop_prune_policy(&mut engine);
assert_eq!(compaction_path_for(&engine), CompactionPath::UnifiedV3);
engine.close().unwrap();
let tombstone_dir = TempDir::new().unwrap();
let mut tombstone_engine = DatabaseEngine::open(tombstone_dir.path(), &opts).unwrap();
let (node_ids, _, _) = build_clean_compaction_fixture(&mut tombstone_engine);
tombstone_engine.delete_node(node_ids[0]).unwrap();
tombstone_engine.flush().unwrap();
assert_eq!(
compaction_path_for(&tombstone_engine),
CompactionPath::UnifiedV3
);
tombstone_engine.close().unwrap();
let overlap_dir = TempDir::new().unwrap();
let overlap_engine = DatabaseEngine::open(overlap_dir.path(), &opts).unwrap();
for _seg in 0..3 {
for i in 0..10 {
overlap_engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
overlap_engine.flush().unwrap();
}
assert_eq!(
compaction_path_for(&overlap_engine),
CompactionPath::UnifiedV3
);
overlap_engine.close().unwrap();
}
#[test]
fn test_fast_merge_compaction_correctness() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut all_node_ids = Vec::new();
let mut all_edge_ids = Vec::new();
for seg in 0..3u64 {
let mut seg_node_ids = Vec::new();
for i in 0..20 {
let id = engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
seg_node_ids.push(id);
all_node_ids.push(id);
}
for i in 0..5 {
let eid = engine
.upsert_edge(
seg_node_ids[i],
seg_node_ids[i + 1],
"RELATES_TO",
UpsertEdgeOptions::default(),
)
.unwrap();
all_edge_ids.push(eid);
}
engine.flush().unwrap();
}
assert_eq!(engine.segments_for_test().len(), 3);
assert!(!engine.segments_for_test().iter().any(|s| s.has_tombstones()));
assert!(segments_are_non_overlapping(&engine.segments_for_test()));
assert_eq!(compaction_path_for(&engine), CompactionPath::FastMerge);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 3);
assert_eq!(stats.nodes_kept, 60);
assert_eq!(stats.edges_kept, 15);
assert_eq!(stats.nodes_removed, 0);
assert_eq!(stats.edges_removed, 0);
assert_eq!(engine.segments_for_test().len(), 1);
assert_segment_uses_packed_core_layout(&segment_dir(
dir.path(),
engine.segments_for_test()[0].segment_id,
));
let node_results = engine.get_nodes(&all_node_ids).unwrap();
for (i, result) in node_results.iter().enumerate() {
assert!(
result.is_some(),
"node {} missing after compact",
all_node_ids[i]
);
}
let edge_results = engine.get_edges(&all_edge_ids).unwrap();
for (i, result) in edge_results.iter().enumerate() {
assert!(
result.is_some(),
"edge {} missing after compact",
all_edge_ids[i]
);
}
for seg in 0..3u64 {
let first_node = all_node_ids[(seg as usize) * 20];
let nbrs = engine
.neighbors(
first_node,
&NeighborOptions {
limit: Some(100),
..Default::default()
},
)
.unwrap();
assert_eq!(nbrs.len(), 1);
}
engine.close().unwrap();
}
#[test]
fn test_fast_merge_with_properties() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut ids = Vec::new();
for seg in 0..2u64 {
for i in 0..10 {
let mut props = BTreeMap::new();
props.insert("seg".to_string(), PropValue::UInt(seg));
props.insert(
"name".to_string(),
PropValue::String(format!("s{}_n{}", seg, i)),
);
let id = engine
.upsert_node(
"Person",
&format!("s{}_n{}", seg, i),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
ids.push(id);
}
engine.flush().unwrap();
}
engine.compact().unwrap();
for (idx, &id) in ids.iter().enumerate() {
let node = engine.get_node(id).unwrap().unwrap();
let seg = (idx / 10) as u64;
assert_eq!(node.props.get("seg"), Some(&PropValue::UInt(seg)));
assert_eq!(
node.props.get("name"),
Some(&PropValue::String(format!("s{}_n{}", seg, idx % 10)))
);
}
engine.close().unwrap();
}
#[test]
fn test_fast_merge_survives_reopen() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let mut ids = Vec::new();
let mut first_nodes = Vec::new();
{
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for seg in 0..3u64 {
let mut seg_ids = Vec::new();
for i in 0..10 {
let id = engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
ids.push(id);
seg_ids.push(id);
}
first_nodes.push(seg_ids[0]);
engine
.upsert_edge(seg_ids[0], seg_ids[1], "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
}
assert_eq!(compaction_path_for(&engine), CompactionPath::FastMerge);
engine.compact().unwrap();
engine.close().unwrap();
}
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for &id in &ids {
assert!(engine.get_node(id).unwrap().is_some());
}
assert_eq!(engine.segments_for_test().len(), 1);
for &first in &first_nodes {
assert_eq!(engine.degree(first, &DegreeOptions::default()).unwrap(), 1);
let nbrs = engine
.neighbors(
first,
&NeighborOptions {
limit: Some(10),
..Default::default()
},
)
.unwrap();
assert_eq!(nbrs.len(), 1);
}
assert!(engine.get_node_by_key("Person", "s0_n0").unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_fast_merge_find_nodes_works() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for seg in 0..2u64 {
for i in 0..20 {
let mut props = BTreeMap::new();
let color = if i % 2 == 0 { "red" } else { "blue" };
props.insert("color".to_string(), PropValue::String(color.to_string()));
engine
.upsert_node(
"Person",
&format!("s{}_n{}", seg, i),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
}
engine.compact().unwrap();
let red = engine
.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap();
assert_eq!(red.len(), 20); let blue = engine
.find_nodes("Person", "color", &PropValue::String("blue".to_string()))
.unwrap();
assert_eq!(blue.len(), 20);
engine.close().unwrap();
}
#[test]
fn test_fast_merge_matches_v3_for_clean_segments() {
let fast_dir = TempDir::new().unwrap();
let v3_dir = TempDir::new().unwrap();
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let mut fast = DatabaseEngine::open(fast_dir.path(), &opts).unwrap();
let mut v3 = DatabaseEngine::open(v3_dir.path(), &opts).unwrap();
let (node_ids, edge_ids, segment_starts) = build_clean_compaction_fixture(&mut fast);
let (v3_node_ids, v3_edge_ids, v3_segment_starts) = build_clean_compaction_fixture(&mut v3);
assert_eq!(node_ids, v3_node_ids);
assert_eq!(edge_ids, v3_edge_ids);
assert_eq!(segment_starts, v3_segment_starts);
install_noop_prune_policy(&mut v3);
assert_eq!(compaction_path_for(&fast), CompactionPath::FastMerge);
assert_eq!(compaction_path_for(&v3), CompactionPath::UnifiedV3);
let fast_stats = fast.compact().unwrap().unwrap();
let v3_stats = v3.compact().unwrap().unwrap();
assert_eq!(fast_stats.nodes_kept, v3_stats.nodes_kept);
assert_eq!(fast_stats.edges_kept, v3_stats.edges_kept);
assert_eq!(fast_stats.nodes_removed, v3_stats.nodes_removed);
assert_eq!(fast_stats.edges_removed, v3_stats.edges_removed);
let fast_nodes = fast.get_nodes(&node_ids).unwrap();
let v3_nodes = v3.get_nodes(&node_ids).unwrap();
assert_node_batches_match(&fast_nodes, &v3_nodes);
let fast_edges = fast.get_edges(&edge_ids).unwrap();
let v3_edges = v3.get_edges(&edge_ids).unwrap();
assert_edge_batches_match(&fast_edges, &v3_edges);
let fast_key = fast.get_node_by_key("Person", "s0_n0").unwrap();
let v3_key = v3.get_node_by_key("Person", "s0_n0").unwrap();
assert_node_batches_match(&[fast_key], &[v3_key]);
for &start in &segment_starts {
assert_eq!(
fast.neighbors(
start,
&NeighborOptions {
limit: Some(10),
..Default::default()
}
)
.unwrap(),
v3.neighbors(
start,
&NeighborOptions {
limit: Some(10),
..Default::default()
}
)
.unwrap()
);
assert_eq!(
fast.degree(start, &DegreeOptions::default()).unwrap(),
v3.degree(start, &DegreeOptions::default()).unwrap()
);
}
assert_eq!(
fast.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap(),
v3.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap()
);
assert_eq!(fast.nodes_by_labels("Person").unwrap(), v3.nodes_by_labels("Person").unwrap());
assert_compacted_index_files_match(&fast, &v3, fast_dir.path(), v3_dir.path());
assert_segment_uses_packed_core_layout(&segment_dir(
fast_dir.path(),
fast.segments_for_test()[0].segment_id,
));
assert_compacted_record_payloads_match_metadata(&segment_dir(
fast_dir.path(),
fast.segments_for_test()[0].segment_id,
));
assert_segment_uses_packed_core_layout(&segment_dir(
v3_dir.path(),
v3.segments_for_test()[0].segment_id,
));
assert_compacted_record_payloads_match_metadata(&segment_dir(
v3_dir.path(),
v3.segments_for_test()[0].segment_id,
));
fast.close().unwrap();
v3.close().unwrap();
}
#[test]
fn test_fast_merge_background_matches_sync() {
let sync_dir = TempDir::new().unwrap();
let bg_dir = TempDir::new().unwrap();
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let mut sync_engine = DatabaseEngine::open(sync_dir.path(), &opts).unwrap();
let mut bg_engine = DatabaseEngine::open(bg_dir.path(), &opts).unwrap();
let (node_ids, edge_ids, segment_starts) = build_clean_compaction_fixture(&mut sync_engine);
let (bg_node_ids, bg_edge_ids, bg_segment_starts) =
build_clean_compaction_fixture(&mut bg_engine);
assert_eq!(node_ids, bg_node_ids);
assert_eq!(edge_ids, bg_edge_ids);
assert_eq!(segment_starts, bg_segment_starts);
assert_eq!(compaction_path_for(&sync_engine), CompactionPath::FastMerge);
assert_eq!(compaction_path_for(&bg_engine), CompactionPath::FastMerge);
let sync_stats = sync_engine.compact().unwrap().unwrap();
bg_engine.start_bg_compact().unwrap();
let bg_stats = bg_engine.wait_for_bg_compact().expect("bg compaction");
assert_eq!(sync_stats.nodes_kept, bg_stats.nodes_kept);
assert_eq!(sync_stats.edges_kept, bg_stats.edges_kept);
let sync_nodes = sync_engine.get_nodes(&node_ids).unwrap();
let bg_nodes = bg_engine.get_nodes(&node_ids).unwrap();
assert_node_batches_match(&sync_nodes, &bg_nodes);
let sync_edges = sync_engine.get_edges(&edge_ids).unwrap();
let bg_edges = bg_engine.get_edges(&edge_ids).unwrap();
assert_edge_batches_match(&sync_edges, &bg_edges);
for &start in &segment_starts {
assert_eq!(
sync_engine
.degree(start, &DegreeOptions::default())
.unwrap(),
bg_engine.degree(start, &DegreeOptions::default()).unwrap()
);
}
assert_compacted_index_files_match(&sync_engine, &bg_engine, sync_dir.path(), bg_dir.path());
assert_segment_metadata_semantics_match(&sync_engine.segments_for_test()[0], &bg_engine.segments_for_test()[0]);
assert_segment_uses_packed_core_layout(&segment_dir(
sync_dir.path(),
sync_engine.segments_for_test()[0].segment_id,
));
assert_segment_uses_packed_core_layout(&segment_dir(
bg_dir.path(),
bg_engine.segments_for_test()[0].segment_id,
));
sync_engine.close().unwrap();
bg_engine.close().unwrap();
}
#[test]
fn test_fast_merge_matches_single_flush_artifacts_for_vector_segments() {
let compact_dir = TempDir::new().unwrap();
let flush_dir = TempDir::new().unwrap();
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 4,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let compact_engine = DatabaseEngine::open(compact_dir.path(), &opts).unwrap();
let flush_engine = DatabaseEngine::open(flush_dir.path(), &opts).unwrap();
let (node_ids, edge_ids) =
build_vector_compaction_and_flush_fixture(&compact_engine, &flush_engine);
assert_eq!(
compaction_path_for(&compact_engine),
CompactionPath::FastMerge
);
compact_engine.compact().unwrap().unwrap();
let compact_nodes = compact_engine.get_nodes(&node_ids).unwrap();
let flush_nodes = flush_engine.get_nodes(&node_ids).unwrap();
assert_node_batches_match(&compact_nodes, &flush_nodes);
let compact_edges = compact_engine.get_edges(&edge_ids).unwrap();
let flush_edges = flush_engine.get_edges(&edge_ids).unwrap();
assert_edge_batches_match(&compact_edges, &flush_edges);
let compact_seg_dir = segment_dir(
compact_dir.path(),
compact_engine.segments_for_test()[0].segment_id,
);
let flush_seg_dir = segment_dir(
flush_dir.path(),
flush_engine.segments_for_test()[0].segment_id,
);
assert_segment_common_artifacts_match(&compact_seg_dir, &flush_seg_dir);
assert_compacted_record_payloads_match_metadata(&compact_seg_dir);
assert_eq!(
record_payload_ids_for_test(
&read_manifest_component_payload_for_test(
&compact_seg_dir,
SegmentComponentKind::NodeRecords,
),
"fast-merge node records",
),
node_ids
);
assert_eq!(
record_payload_ids_for_test(
&read_manifest_component_payload_for_test(
&compact_seg_dir,
SegmentComponentKind::EdgeRecords,
),
"fast-merge edge records",
),
edge_ids
);
assert_segment_metadata_semantics_match(
&compact_engine.segments_for_test()[0],
&flush_engine.segments_for_test()[0],
);
let queries: Vec<Vec<f32>> = vec![
vec![1.0, 0.2, 0.4, 0.6],
vec![0.5, 0.5, 0.5, 0.5],
vec![1.1, 0.35, 0.45, 0.7],
];
for query in &queries {
let request = VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(query.clone()),
sparse_query: None,
k: 5,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
};
let compact_hits = compact_engine.vector_search(&request).unwrap();
let flush_hits = flush_engine.vector_search(&request).unwrap();
assert_eq!(
compact_hits.len(),
flush_hits.len(),
"hit count mismatch for query {:?}",
query
);
assert_eq!(
compact_hits[0].node_id, flush_hits[0].node_id,
"top-1 mismatch for query {:?}: compact={} flush={}",
query, compact_hits[0].node_id, flush_hits[0].node_id
);
let compact_ids: std::collections::HashSet<u64> =
compact_hits.iter().map(|h| h.node_id).collect();
let flush_ids: std::collections::HashSet<u64> =
flush_hits.iter().map(|h| h.node_id).collect();
let overlap = compact_ids.intersection(&flush_ids).count();
assert!(
overlap >= 3,
"low overlap ({}/5) for query {:?}",
overlap,
query
);
}
compact_engine.close().unwrap();
flush_engine.close().unwrap();
}
#[test]
fn test_v3_matches_single_flush_artifacts_for_vector_segments() {
let compact_dir = TempDir::new().unwrap();
let flush_dir = TempDir::new().unwrap();
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 4,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let mut compact_engine = DatabaseEngine::open(compact_dir.path(), &opts).unwrap();
let flush_engine = DatabaseEngine::open(flush_dir.path(), &opts).unwrap();
let (node_ids, edge_ids) =
build_vector_compaction_and_flush_fixture(&compact_engine, &flush_engine);
install_noop_prune_policy(&mut compact_engine);
assert_eq!(
compaction_path_for(&compact_engine),
CompactionPath::UnifiedV3
);
compact_engine.compact().unwrap().unwrap();
let compact_nodes = compact_engine.get_nodes(&node_ids).unwrap();
let flush_nodes = flush_engine.get_nodes(&node_ids).unwrap();
assert_node_batches_match(&compact_nodes, &flush_nodes);
let compact_edges = compact_engine.get_edges(&edge_ids).unwrap();
let flush_edges = flush_engine.get_edges(&edge_ids).unwrap();
assert_edge_batches_match(&compact_edges, &flush_edges);
let compact_seg_dir = segment_dir(
compact_dir.path(),
compact_engine.segments_for_test()[0].segment_id,
);
let flush_seg_dir = segment_dir(
flush_dir.path(),
flush_engine.segments_for_test()[0].segment_id,
);
assert_segment_uses_packed_core_layout(&compact_seg_dir);
assert_segment_uses_packed_core_layout(&flush_seg_dir);
assert_segment_common_artifacts_match(&compact_seg_dir, &flush_seg_dir);
assert_record_artifacts_match(&compact_seg_dir, &flush_seg_dir);
assert_compacted_record_payloads_match_metadata(&compact_seg_dir);
assert_segment_metadata_semantics_match(
&compact_engine.segments_for_test()[0],
&flush_engine.segments_for_test()[0],
);
compact_engine.close().unwrap();
flush_engine.close().unwrap();
}
#[test]
fn test_standard_path_used_for_overlapping_segments() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for _seg in 0..3 {
for i in 0..10 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
assert_eq!(compaction_path_for(&engine), CompactionPath::UnifiedV3);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 3);
assert_eq!(stats.nodes_kept, 10); assert_eq!(engine.segments_for_test().len(), 1);
for i in 0..10 {
assert!(engine
.get_node(
engine
.find_existing_node(1, &format!("n{}", i))
.unwrap()
.unwrap()
.0
)
.unwrap()
.is_some());
}
engine.close().unwrap();
}
#[test]
fn test_v3_filtered_compaction_packed_record_payloads_match_winners() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
edge_uniqueness: true,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut node_1_old = make_node(1, "node-1-old");
node_1_old.weight = 1.0;
let mut node_2 = make_node(2, "node-2");
node_2.weight = 2.0;
let mut node_3 = make_node(3, "node-3");
node_3.weight = 3.0;
write_internal_wal_op(&engine, &WalOp::UpsertNode(node_1_old)).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(node_2)).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(node_3)).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertEdge(make_edge(10, 1, 2))).unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertEdge(make_edge(11, 2, 3))).unwrap();
let mut edge_12_old = make_edge(12, 3, 1);
edge_12_old.weight = 1.25;
write_internal_wal_op(&engine, &WalOp::UpsertEdge(edge_12_old)).unwrap();
engine.flush().unwrap();
let mut node_1_new = make_node(1, "node-1-new");
node_1_new.weight = 4.0;
write_internal_wal_op(&engine, &WalOp::UpsertNode(node_1_new)).unwrap();
write_internal_wal_op(&engine, &WalOp::DeleteNode {
id: 3,
deleted_at: 9_000,
})
.unwrap();
write_internal_wal_op(&engine, &WalOp::DeleteEdge {
id: 10,
deleted_at: 9_001,
})
.unwrap();
let mut edge_12_new = make_edge(12, 2, 1);
edge_12_new.weight = 2.5;
write_internal_wal_op(&engine, &WalOp::UpsertEdge(edge_12_new)).unwrap();
engine.flush().unwrap();
assert_eq!(compaction_path_for(&engine), CompactionPath::UnifiedV3);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 2);
assert_eq!(stats.edges_kept, 1);
let compacted_seg_dir = segment_dir(dir.path(), engine.segments_for_test()[0].segment_id);
assert_segment_uses_packed_core_layout(&compacted_seg_dir);
let node_payload =
read_manifest_component_payload_for_test(&compacted_seg_dir, SegmentComponentKind::NodeRecords);
let edge_payload =
read_manifest_component_payload_for_test(&compacted_seg_dir, SegmentComponentKind::EdgeRecords);
assert_eq!(
record_payload_ids_for_test(&node_payload, "v3 filtered node records"),
vec![1, 2]
);
assert_eq!(
record_payload_ids_for_test(&edge_payload, "v3 filtered edge records"),
vec![12]
);
let node_metadata_ids = assert_metadata_offsets_match_record_payload_for_test(
&compacted_seg_dir,
SegmentComponentKind::NodeRecords,
SegmentComponentKind::NodeMetadata,
60,
"v3 filtered node records",
);
let edge_metadata_ids = assert_metadata_offsets_match_record_payload_for_test(
&compacted_seg_dir,
SegmentComponentKind::EdgeRecords,
SegmentComponentKind::EdgeMetadata,
80,
"v3 filtered edge records",
);
assert_eq!(node_metadata_ids, vec![1, 2]);
assert_eq!(edge_metadata_ids, vec![12]);
let node_1 = engine.get_node(1).unwrap().unwrap();
assert_eq!(node_1.key, "node-1-new");
assert_eq!(node_1.weight.to_bits(), 4.0f32.to_bits());
assert!(engine.get_node(3).unwrap().is_none());
assert!(engine.get_edge(10).unwrap().is_none());
assert!(engine.get_edge(11).unwrap().is_none());
let edge_12 = engine.get_edge(12).unwrap().unwrap();
assert_eq!((edge_12.from, edge_12.to), (2, 1));
assert_eq!(edge_12.weight.to_bits(), 2.5f32.to_bits());
engine.close().unwrap();
}
#[test]
fn test_auto_compact_triggers_after_n_flushes() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 3,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert_eq!(engine.segments_for_test().len(), 1);
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert_eq!(engine.segments_for_test().len(), 2);
for i in 0..10 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 1);
for prefix in ["a", "b", "c"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after auto-compact",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_auto_compact_disabled_when_zero() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for flush in 0..10u64 {
for i in 0..5 {
engine
.upsert_node(
"Person",
&format!("f{}_n{}", flush, i),
UpsertNodeOptions::default(),
)
.unwrap();
}
engine.flush().unwrap();
}
assert_eq!(engine.segments_for_test().len(), 10);
engine.close().unwrap();
}
#[test]
fn test_auto_compact_counter_resets_on_manual_compact() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 5,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for seg in 0..2u64 {
for i in 0..5 {
engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
assert_eq!(engine.segments_for_test().len(), 2);
assert_eq!(engine.flush_count_since_last_compact_for_test(), 2);
engine.compact().unwrap();
assert_eq!(engine.flush_count_since_last_compact_for_test(), 0);
assert_eq!(engine.segments_for_test().len(), 1);
for seg in 2..6u64 {
for i in 0..5 {
engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
assert_eq!(engine.segments_for_test().len(), 5);
for i in 0..5 {
engine
.upsert_node("Person", &format!("s6_n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 1);
engine.close().unwrap();
}
#[test]
fn test_auto_compact_data_integrity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut all_ids = Vec::new();
for seg in 0..6u64 {
for i in 0..10 {
let id = engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
all_ids.push(id);
}
engine.flush().unwrap();
}
for &id in &all_ids {
assert!(
engine.get_node(id).unwrap().is_some(),
"node {} missing after auto-compactions",
id
);
}
engine.close().unwrap();
}
#[test]
fn test_auto_compact_not_triggered_during_compact_flush() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 1, ..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert_eq!(engine.segments_for_test().len(), 1);
for i in 0..5 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 1);
for i in 0..5 {
assert!(engine
.find_existing_node(1, &format!("a{}", i))
.unwrap()
.is_some());
assert!(engine
.find_existing_node(1, &format!("b{}", i))
.unwrap()
.is_some());
}
engine.close().unwrap();
}
#[test]
fn test_bg_compact_basic() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert_eq!(engine.segments_for_test().len(), 1);
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert!(engine.bg_compact_active_for_test() || engine.segments_for_test().len() == 1);
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 1);
for prefix in ["a", "b"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after bg compact",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_lifecycle_pump_applies_finished_bg_compact_without_foreground_write() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert!(engine.bg_compact_active_for_test());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while engine.bg_compact_active_for_test() {
assert!(
std::time::Instant::now() < deadline,
"lifecycle pump did not reap finished background compaction in time"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
!engine.bg_compact_incomplete_for_test(),
"finished background compaction should no longer be outstanding"
);
assert_eq!(engine.segments_for_test().len(), 1);
engine.close().unwrap();
}
#[test]
fn test_lifecycle_pump_applies_bg_flush_without_foreground_write() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
db.upsert_node("Person", "pump_flush", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.enqueue_one_flush().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while db.immutable_epoch_count() > 0 {
assert!(
std::time::Instant::now() < deadline,
"lifecycle pump did not adopt completed bg flush in time"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(db.segment_count().unwrap(), 1);
assert!(db.get_node_by_key("Person", "pump_flush").unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_bg_compact_writes_during() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..20 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.close().unwrap();
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for prefix in ["a", "b", "c"] {
let count = if prefix == "c" { 20 } else { 10 };
for i in 0..count {
let key = format!("{}{}", prefix, i);
assert!(
engine
.get_node(engine.find_existing_node(1, &key).unwrap().unwrap().0)
.unwrap()
.is_some(),
"node {} missing after bg compact + writes",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_flushes_while_bg_compact_is_outstanding_count_toward_next_run() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert!(engine.bg_compact_active_for_test());
assert_eq!(engine.flush_count_since_last_compact_for_test(), 0);
for i in 0..10 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert_eq!(
engine.flush_count_since_last_compact_for_test(), 1,
"flushes published while background compaction is outstanding should count toward the next auto-compaction"
);
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 2);
for i in 0..10 {
engine
.upsert_node("Person", &format!("d{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert!(
engine.bg_compact_active_for_test(),
"second auto-compaction should start once the post-compaction flush count reaches the threshold"
);
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 1);
engine.close().unwrap();
}
#[test]
fn test_bg_compact_flush_during() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
memtable_flush_threshold: 0, memtable_hard_cap_bytes: 0, wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 2);
for prefix in ["a", "b", "c"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after bg compact + flush during",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_bg_compact_no_double() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 1, memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert!(!engine.bg_compact_active_for_test());
for i in 0..5 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
let had_bg = engine.bg_compact_active_for_test();
for i in 0..5 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.wait_for_bg_compact();
for prefix in ["a", "b", "c"] {
for i in 0..5 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing",
key
);
}
}
engine.close().unwrap();
let _ = had_bg; }
#[test]
fn test_bg_compact_manual_after_bg() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("d{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
let _stats = engine.compact().unwrap();
assert_eq!(engine.segments_for_test().len(), 1);
for prefix in ["a", "b", "c", "d"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after manual compact",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_bg_compact_drop_waits() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
drop(engine);
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for prefix in ["a", "b"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after drop + reopen",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_bg_compact_immediate_mode() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.wait_for_bg_compact();
assert_eq!(engine.segments_for_test().len(), 1);
engine.close().unwrap();
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for prefix in ["a", "b"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after bg compact (immediate mode)",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_bg_compact_group_commit_mode() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 5,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("c{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.close().unwrap();
let opts_reopen = DbOptions {
compact_after_n_flushes: 0, wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts_reopen).unwrap();
for prefix in ["a", "b", "c"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after bg compact (group commit mode)",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_bg_compact_cancel() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 2,
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("a{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_node("Person", &format!("b{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
engine.cancel_bg_compact();
assert!(!engine.bg_compact_active_for_test());
for prefix in ["a", "b"] {
for i in 0..10 {
let key = format!("{}{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after cancel",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_orphan_segment_cleanup_on_open() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0, wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
assert_eq!(engine.segments_for_test().len(), 1);
engine.close().unwrap();
let orphan1 = segment_dir(dir.path(), 9990);
let orphan2 = segment_dir(dir.path(), 9991);
std::fs::create_dir_all(&orphan1).unwrap();
std::fs::create_dir_all(&orphan2).unwrap();
std::fs::write(orphan1.join("dummy.dat"), b"orphan").unwrap();
std::fs::write(orphan2.join("dummy.dat"), b"orphan").unwrap();
assert!(orphan1.exists());
assert!(orphan2.exists());
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert!(!orphan1.exists(), "orphan1 should have been cleaned up");
assert!(!orphan2.exists(), "orphan2 should have been cleaned up");
assert_eq!(engine.segments_for_test().len(), 1);
for i in 0..5 {
let key = format!("n{}", i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after orphan cleanup",
key
);
}
engine.close().unwrap();
}
#[test]
fn test_orphan_cleanup_preserves_valid_segments() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for seg in 0..3 {
for i in 0..5 {
engine
.upsert_node("Person", &format!("s{}_n{}", seg, i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
assert_eq!(engine.segments_for_test().len(), 3);
engine.close().unwrap();
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(engine.segments_for_test().len(), 3);
for seg in 0..3 {
for i in 0..5 {
let key = format!("s{}_n{}", seg, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing",
key
);
}
}
engine.close().unwrap();
}
fn temp_db_immediate() -> (TempDir, DatabaseEngine) {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
create_if_missing: true,
edge_uniqueness: true,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
(dir, engine)
}
fn temp_db_group_commit() -> (TempDir, DatabaseEngine) {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
create_if_missing: true,
edge_uniqueness: true,
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 5,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
(dir, engine)
}
#[test]
fn test_immediate_mode_basic_operations() {
let (dir, engine) = temp_db_immediate();
let n1 = engine
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(n1, n2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
assert!(engine.get_node(n1).unwrap().is_some());
assert!(engine.get_node(n2).unwrap().is_some());
assert!(engine.get_edge(e1).unwrap().is_some());
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
assert!(engine.get_node(n1).unwrap().is_some());
assert!(engine.get_node(n2).unwrap().is_some());
assert!(engine.get_edge(e1).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_immediate_mode_batch_operations() {
let (_dir, engine) = temp_db_immediate();
let inputs: Vec<NodeInput> = (0..50)
.map(|i| NodeInput {
labels: vec!["Person".to_string()],
key: format!("node_{}", i),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
})
.collect();
let ids = engine.batch_upsert_nodes(inputs).unwrap();
assert_eq!(ids.len(), 50);
for &id in &ids {
assert!(engine.get_node(id).unwrap().is_some());
}
engine.close().unwrap();
}
#[test]
fn test_immediate_mode_flush_compact_cycle() {
let (_dir, engine) = temp_db_immediate();
for i in 0..100 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 100..200 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
let stats = engine.compact().unwrap();
assert!(stats.is_some());
for i in 0..200 {
assert!(
engine
.get_node(
engine
.find_existing_node(1, &format!("n{}", i))
.unwrap()
.unwrap()
.0
)
.unwrap()
.is_some(),
"node n{} missing after compact",
i
);
}
engine.close().unwrap();
}
#[test]
fn test_group_commit_basic_write_close_reopen() {
let (dir, engine) = temp_db_group_commit();
let mut ids = Vec::new();
for i in 0..20 {
let id = engine
.upsert_node("Person", &format!("gc_node_{}", i), UpsertNodeOptions::default())
.unwrap();
ids.push(id);
}
for &id in &ids {
assert!(engine.get_node(id).unwrap().is_some());
}
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for &id in &ids {
assert!(
engine.get_node(id).unwrap().is_some(),
"node {} missing after reopen",
id
);
}
engine.close().unwrap();
}
#[test]
fn test_group_commit_with_edges() {
let (dir, engine) = temp_db_group_commit();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(
n1,
n2,
"RELATES_TO",
UpsertEdgeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let neighbors = engine
.neighbors(
n1,
&NeighborOptions {
limit: Some(10),
..Default::default()
},
)
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, n2);
engine.close().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert!(engine.get_edge(e1).unwrap().is_some());
let edge = engine.get_edge(e1).unwrap().unwrap();
assert_eq!(edge.from, n1);
assert_eq!(edge.to, n2);
engine.close().unwrap();
}
#[test]
fn test_group_commit_batch_operations() {
let (dir, engine) = temp_db_group_commit();
let inputs: Vec<NodeInput> = (0..100)
.map(|i| NodeInput {
labels: vec!["Person".to_string()],
key: format!("batch_{}", i),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
})
.collect();
let ids = engine.batch_upsert_nodes(inputs).unwrap();
assert_eq!(ids.len(), 100);
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for &id in &ids {
assert!(
engine.get_node(id).unwrap().is_some(),
"batch node {} missing",
id
);
}
engine.close().unwrap();
}
#[test]
fn test_sync_forces_immediate_flush() {
let (dir, engine) = temp_db_group_commit();
let id = engine
.upsert_node("Person", "sync_test", UpsertNodeOptions::default())
.unwrap();
engine.sync().unwrap();
drop(engine);
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
assert!(
engine.get_node(id).unwrap().is_some(),
"sync'd data missing after drop"
);
engine.close().unwrap();
}
#[test]
fn test_sync_noop_in_immediate_mode() {
let (_dir, engine) = temp_db_immediate();
engine
.upsert_node("Person", "test", UpsertNodeOptions::default())
.unwrap();
engine.sync().unwrap();
engine.close().unwrap();
}
#[test]
fn test_group_commit_flush_cycle() {
let (dir, engine) = temp_db_group_commit();
for i in 0..50 {
engine
.upsert_node("Person", &format!("pre_flush_{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..50 {
engine
.upsert_node(
"Person",
&format!("post_flush_{}", i),
UpsertNodeOptions::default(),
)
.unwrap();
}
engine.flush().unwrap();
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for i in 0..50 {
assert!(engine
.find_existing_node(1, &format!("pre_flush_{}", i))
.unwrap()
.is_some());
assert!(engine
.find_existing_node(1, &format!("post_flush_{}", i))
.unwrap()
.is_some());
}
engine.close().unwrap();
}
#[test]
fn test_drop_joins_sync_thread() {
let (_dir, engine) = temp_db_group_commit();
for i in 0..10 {
engine
.upsert_node("Person", &format!("drop_test_{}", i), UpsertNodeOptions::default())
.unwrap();
}
drop(engine);
}
#[test]
fn test_default_options_use_group_commit() {
let opts = DbOptions::default();
assert!(matches!(
opts.wal_sync_mode,
WalSyncMode::GroupCommit { .. }
));
}
#[test]
fn test_backpressure_blocks_writer_at_hard_cap() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
create_if_missing: true,
edge_uniqueness: false,
compact_after_n_flushes: 0, wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 1,
soft_trigger_bytes: 128,
hard_cap_bytes: 256,
},
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..200 {
engine
.upsert_node("Person", &format!("bp_{}", i), UpsertNodeOptions::default())
.unwrap();
}
for i in 0..200 {
assert!(
engine
.find_existing_node(1, &format!("bp_{}", i))
.unwrap()
.is_some(),
"node bp_{} missing after backpressure writes",
i
);
}
engine.close().unwrap();
}
#[test]
fn test_clean_shutdown_drains_all_buffered_data() {
let (dir, engine) = temp_db_group_commit();
for i in 0..100 {
engine
.upsert_node("Person", &format!("drain_{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for i in 0..100 {
assert!(
engine
.find_existing_node(1, &format!("drain_{}", i))
.unwrap()
.is_some(),
"node drain_{} lost during shutdown",
i
);
}
engine.close().unwrap();
}
#[test]
fn test_drop_drains_buffered_data() {
let dir = TempDir::new().unwrap();
{
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
create_if_missing: true,
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 5,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
..DbOptions::default()
},
)
.unwrap();
for i in 0..50 {
engine
.upsert_node(
"Person",
&format!("drop_drain_{}", i),
UpsertNodeOptions::default(),
)
.unwrap();
}
drop(engine);
}
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for i in 0..50 {
assert!(
engine
.find_existing_node(1, &format!("drop_drain_{}", i))
.unwrap()
.is_some(),
"node drop_drain_{} lost after drop",
i
);
}
engine.close().unwrap();
}
#[test]
fn test_sync_failure_poisons_engine() {
use crate::wal_sync::WalSyncState;
let dir = TempDir::new().unwrap();
let wal_dir = TempDir::new().unwrap();
let writer = WalWriter::open_generation(wal_dir.path(), 0).unwrap();
let state = WalSyncState {
wal_writer: writer,
buffered_bytes: 0,
shutdown: false,
sync_error_count: 0,
poisoned: Some("test: WAL sync failed 5 times".to_string()),
};
let arc = std::sync::Arc::new((std::sync::Mutex::new(state), std::sync::Condvar::new()));
let opts = DbOptions {
create_if_missing: true,
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 1000, soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine.replace_wal_state_for_test(arc).unwrap();
let before = engine.published_state();
engine.reset_publish_counters_for_test();
let result = engine.upsert_node("Person", "should_fail", UpsertNodeOptions::default());
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("WAL sync failed"),
"unexpected error: {}",
err_msg
);
let after = engine.published_state();
let counters = engine.publish_counter_snapshot_for_test();
assert!(std::sync::Arc::ptr_eq(&before.view.sources, &after.view.sources));
assert_eq!(before.engine_seq, after.engine_seq);
assert!(engine.get_node_by_key("Person", "should_fail").unwrap().is_none());
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 0);
assert_eq!(counters.rebuild_sources, 0);
assert_eq!(counters.source_rebuilds, 0);
}
#[test]
fn test_integration_1000_writes_group_commit() {
let (dir, engine) = temp_db_group_commit();
for i in 0..1000 {
let mut props = BTreeMap::new();
props.insert("index".to_string(), PropValue::Int(i as i64));
engine
.upsert_node(
"Person",
&format!("int_{}", i),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for i in 0..1000 {
let (id, _) = engine
.find_existing_node(1, &format!("int_{}", i))
.unwrap()
.unwrap_or_else(|| panic!("node int_{} missing", i));
let node = engine
.get_node(id)
.unwrap()
.unwrap_or_else(|| panic!("node {} not found by id", id));
assert_eq!(node.props.get("index"), Some(&PropValue::Int(i as i64)));
}
engine.close().unwrap();
}
#[test]
fn test_integration_write_flush_write_flush_group_commit() {
let (dir, engine) = temp_db_group_commit();
for i in 0..100 {
engine
.upsert_node("Person", &format!("c1_{}", i), UpsertNodeOptions::default())
.unwrap();
}
let seg1 = engine.flush().unwrap();
assert!(seg1.is_some());
for i in 0..100 {
engine
.upsert_node("Person", &format!("c2_{}", i), UpsertNodeOptions::default())
.unwrap();
}
let seg2 = engine.flush().unwrap();
assert!(seg2.is_some());
for i in 0..100 {
engine
.upsert_node("Person", &format!("c3_{}", i), UpsertNodeOptions::default())
.unwrap();
}
let seg3 = engine.flush().unwrap();
assert!(seg3.is_some());
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for prefix in &["c1", "c2", "c3"] {
for i in 0..100 {
let key = format!("{}_{}", prefix, i);
assert!(
engine.find_existing_node(1, &key).unwrap().is_some(),
"node {} missing after multi-flush cycle",
key
);
}
}
engine.close().unwrap();
}
#[test]
fn test_group_commit_delete_and_compact_cycle() {
let (dir, engine) = temp_db_group_commit();
let mut ids = Vec::new();
for i in 0..100 {
let id = engine
.upsert_node("Person", &format!("gc_del_{}", i), UpsertNodeOptions::default())
.unwrap();
ids.push(id);
}
engine.flush().unwrap();
for &id in &ids[..50] {
engine.delete_node(id).unwrap();
}
engine.flush().unwrap();
let stats = engine.compact().unwrap();
assert!(stats.is_some());
let stats = stats.unwrap();
assert!(stats.nodes_removed > 0);
for &id in &ids[..50] {
assert!(
engine.get_node(id).unwrap().is_none(),
"deleted node {} still present",
id
);
}
for &id in &ids[50..] {
assert!(
engine.get_node(id).unwrap().is_some(),
"surviving node {} missing",
id
);
}
engine.close().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
for &id in &ids[..50] {
assert!(engine.get_node(id).unwrap().is_none());
}
for &id in &ids[50..] {
assert!(engine.get_node(id).unwrap().is_some());
}
engine.close().unwrap();
}
#[test]
fn test_group_commit_rejects_invalid_parameters() {
let dir = TempDir::new().unwrap();
let result = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 0,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
..DbOptions::default()
},
);
assert!(result.is_err());
let result = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 10,
soft_trigger_bytes: 0,
hard_cap_bytes: 16 * 1024 * 1024,
},
..DbOptions::default()
},
);
assert!(result.is_err());
let result = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 10,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 0,
},
..DbOptions::default()
},
);
assert!(result.is_err());
let result = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 10,
soft_trigger_bytes: 1024,
hard_cap_bytes: 1024,
},
..DbOptions::default()
},
);
assert!(result.is_err());
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 10,
soft_trigger_bytes: 1024,
hard_cap_bytes: 2048,
},
..DbOptions::default()
},
)
.unwrap();
engine.close().unwrap();
}
#[test]
fn test_backpressure_flush_triggers_at_hard_cap_immediate() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0, memtable_hard_cap_bytes: 512, wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0, ..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(engine.segment_count().unwrap(), 0);
let mut ids = Vec::new();
for i in 0..50 {
let id = engine
.upsert_node(
"Person",
&format!("bp_imm_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
ids.push(id);
}
assert!(
engine.segment_count().unwrap() >= 1,
"expected at least 1 segment from backpressure flush"
);
for (i, &id) in ids.iter().enumerate() {
let node = engine.get_node(id).unwrap().unwrap();
assert_eq!(node.key, format!("bp_imm_{}", i));
}
engine.close().unwrap();
}
#[test]
fn test_backpressure_flush_triggers_at_hard_cap_group_commit() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0, memtable_hard_cap_bytes: 512, wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 5,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(engine.segment_count().unwrap(), 0);
let mut ids = Vec::new();
for i in 0..50 {
let id = engine
.upsert_node(
"Person",
&format!("bp_gc_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
ids.push(id);
}
assert!(
engine.segment_count().unwrap() >= 1,
"expected backpressure flush in group commit mode"
);
for (i, &id) in ids.iter().enumerate() {
let node = engine.get_node(id).unwrap().unwrap();
assert_eq!(node.key, format!("bp_gc_{}", i));
}
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for &id in &ids {
assert!(
engine.get_node(id).unwrap().is_some(),
"node {} missing after reopen",
id
);
}
engine.close().unwrap();
}
#[test]
fn test_backpressure_disabled_when_zero() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0, wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..100 {
engine
.upsert_node(
"Person",
&format!("no_bp_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
assert_eq!(engine.segment_count().unwrap(), 0);
assert_eq!(engine.node_count().unwrap(), 100);
engine.close().unwrap();
}
#[test]
fn test_backpressure_fires_before_soft_threshold() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 1024 * 1024, memtable_hard_cap_bytes: 512, wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..30 {
engine
.upsert_node(
"Person",
&format!("early_bp_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
assert!(
engine.segment_count().unwrap() >= 1,
"backpressure should trigger before soft threshold"
);
engine.close().unwrap();
}
#[test]
fn test_backpressure_with_edges_and_deletes() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 512,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut node_ids = Vec::new();
for i in 0..20 {
let id = engine
.upsert_node(
"Person",
&format!("n_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
node_ids.push(id);
}
let mut edge_ids = Vec::new();
for i in 0..19 {
let eid = engine
.upsert_edge(
node_ids[i],
node_ids[i + 1],
"RELATES_TO",
UpsertEdgeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
edge_ids.push(eid);
}
for nid in &node_ids[..5] {
engine.delete_node(*nid).unwrap();
}
for eid in &edge_ids[..3] {
engine.delete_edge(*eid).unwrap();
}
assert!(engine.segment_count().unwrap() >= 1);
for nid in &node_ids[5..20] {
assert!(engine.get_node(*nid).unwrap().is_some());
}
engine.close().unwrap();
}
#[test]
fn test_backpressure_with_batch_upserts() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 512,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let inputs1: Vec<NodeInput> = (0..20)
.map(|i| NodeInput {
labels: vec!["Person".to_string()],
key: format!("batch1_{}", i),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
})
.collect();
let ids1 = engine.batch_upsert_nodes(inputs1).unwrap();
let inputs2: Vec<NodeInput> = (0..20)
.map(|i| NodeInput {
labels: vec!["Person".to_string()],
key: format!("batch2_{}", i),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
})
.collect();
let ids2 = engine.batch_upsert_nodes(inputs2).unwrap();
assert!(
engine.segment_count().unwrap() >= 1,
"backpressure should flush during batch ops"
);
for &id in ids1.iter().chain(ids2.iter()) {
assert!(engine.get_node(id).unwrap().is_some());
}
engine.close().unwrap();
}
#[test]
fn test_backpressure_flush_then_write_cycle_group_commit() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 256,
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 2,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut ids = Vec::new();
for i in 0..200 {
let id = engine
.upsert_node(
"Person",
&format!("stress_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
ids.push(id);
}
assert!(
engine.segment_count().unwrap() >= 5,
"expected many backpressure flushes"
);
for (i, &id) in ids.iter().enumerate() {
assert!(
engine.get_node(id).unwrap().is_some(),
"node stress_{} (id={}) missing",
i,
id
);
}
engine.close().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
},
)
.unwrap();
for &id in &ids {
assert!(
engine.get_node(id).unwrap().is_some(),
"node {} missing after reopen",
id
);
}
engine.close().unwrap();
}
#[test]
fn test_backpressure_interacts_with_auto_compact() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 512,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 3, ..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..100 {
engine
.upsert_node(
"Person",
&format!("ac_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..100 {
assert!(
engine
.find_existing_node(1, &format!("ac_{}", i))
.unwrap()
.is_some(),
"node ac_{} missing after backpressure + auto-compact",
i
);
}
engine.close().unwrap();
}
#[test]
fn test_backpressure_invalidate_edge() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 512,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let n1 = engine
.upsert_node("Person", "src", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "dst", UpsertNodeOptions::default())
.unwrap();
let mut edge_ids = Vec::new();
let edge_labels = [
"RELATES_TO",
"WORKS_AT",
"LIKES",
"MENTIONS",
"OWNS",
"FOLLOWS",
"FRIENDS_WITH",
"COLLABORATES_WITH",
"RELATED_TO",
"KNOWS",
"BLOCKS",
"DEPENDS_ON",
"ASSIGNED_TO",
"REVIEWED_BY",
"PUBLISHED_BY",
"TAGGED_WITH",
"EDGE_LABEL_17",
"EDGE_LABEL_18",
"EDGE_LABEL_19",
"REPORTS_TO",
];
for label in edge_labels {
let eid = engine
.upsert_edge(
n1,
n2,
label,
UpsertEdgeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
edge_ids.push(eid);
}
for &eid in &edge_ids {
engine.invalidate_edge(eid, 999).unwrap();
}
assert!(
engine.segment_count().unwrap() >= 1,
"backpressure should flush during invalidate_edge"
);
engine.close().unwrap();
}
#[test]
fn test_compact_all_records_tombstoned() {
let dir = TempDir::new().unwrap();
let db = open_imm(&dir.path().join("db"));
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.delete_node(a).unwrap();
db.delete_node(b).unwrap();
db.delete_edge(e).unwrap();
db.flush().unwrap();
let stats = db.compact().unwrap();
assert!(stats.is_some());
let stats = stats.unwrap();
assert_eq!(stats.nodes_kept, 0);
assert_eq!(stats.edges_kept, 0);
assert_eq!(stats.nodes_removed, 2);
assert_eq!(stats.edges_removed, 1);
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
assert!(db.get_node(c).unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_engine_seq_monotonic_across_writes() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let id2 = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let id3 = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let seq1 = internal_node_record(&db, id1).unwrap().unwrap().last_write_seq;
let seq2 = internal_node_record(&db, id2).unwrap().unwrap().last_write_seq;
let seq3 = internal_node_record(&db, id3).unwrap().unwrap().last_write_seq;
assert!(seq1 > 0, "seq must be > 0");
assert!(seq2 > seq1, "seq2 ({}) must be > seq1 ({})", seq2, seq1);
assert!(seq3 > seq2, "seq3 ({}) must be > seq2 ({})", seq3, seq2);
db.close().unwrap();
}
#[test]
fn test_engine_seq_survives_flush() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let id3 = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let seq3 = internal_node_record(&db, id3).unwrap().unwrap().last_write_seq;
assert!(
seq3 >= 3,
"seq after flush must continue monotonically, got {}",
seq3
);
db.close().unwrap();
}
#[test]
fn test_engine_seq_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id3 = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let seq3 = internal_node_record(&db, id3).unwrap().unwrap().last_write_seq;
assert!(seq3 >= 3, "seq after reopen must be >= 3, got {}", seq3);
db.close().unwrap();
}
}
#[test]
fn test_engine_seq_correct_after_replay() {
let dir = tempfile::tempdir().unwrap();
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let n1 = internal_node_record(&db, 1).unwrap().unwrap();
let n2 = internal_node_record(&db, 2).unwrap().unwrap();
assert!(n1.last_write_seq > 0);
assert!(n2.last_write_seq > n1.last_write_seq);
let id3 = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let seq3 = internal_node_record(&db, id3).unwrap().unwrap().last_write_seq;
assert!(seq3 > n2.last_write_seq);
db.close().unwrap();
}
}
#[test]
fn test_last_write_seq_exact_equality_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("seq_exact");
let (seq_a, seq_b, seq_c);
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id_a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let id_b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let id_c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
seq_a = internal_node_record(&db, id_a).unwrap().unwrap().last_write_seq;
seq_b = internal_node_record(&db, id_b).unwrap().unwrap().last_write_seq;
seq_c = internal_node_record(&db, id_c).unwrap().unwrap().last_write_seq;
assert!(seq_a < seq_b && seq_b < seq_c);
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(
internal_node_record(&db, 1).unwrap().unwrap().last_write_seq,
seq_a,
"node a seq changed after replay"
);
assert_eq!(
internal_node_record(&db, 2).unwrap().unwrap().last_write_seq,
seq_b,
"node b seq changed after replay"
);
assert_eq!(
internal_node_record(&db, 3).unwrap().unwrap().last_write_seq,
seq_c,
"node c seq changed after replay"
);
let id_d = db
.upsert_node("Person", "d", UpsertNodeOptions::default())
.unwrap();
let seq_d = internal_node_record(&db, id_d).unwrap().unwrap().last_write_seq;
assert!(seq_d > seq_c, "new write seq must be > replayed max");
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(
internal_node_record(&db, 1).unwrap().unwrap().last_write_seq,
seq_a
);
assert_eq!(
internal_node_record(&db, 2).unwrap().unwrap().last_write_seq,
seq_b
);
assert_eq!(
internal_node_record(&db, 3).unwrap().unwrap().last_write_seq,
seq_c
);
db.close().unwrap();
}
}
#[test]
fn test_last_write_seq_exact_across_freeze_reopen() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("seq_freeze");
let (seq_frozen, seq_active);
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id_f = db
.upsert_node("Person", "frozen_node", UpsertNodeOptions::default())
.unwrap();
seq_frozen = internal_node_record(&db, id_f).unwrap().unwrap().last_write_seq;
db.freeze_memtable().unwrap();
let id_a = db
.upsert_node("Person", "active_node", UpsertNodeOptions::default())
.unwrap();
seq_active = internal_node_record(&db, id_a).unwrap().unwrap().last_write_seq;
assert!(seq_active > seq_frozen);
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let f = internal_node_record(&db, 1).unwrap().unwrap();
let a = internal_node_record(&db, 2).unwrap().unwrap();
assert_eq!(f.last_write_seq, seq_frozen, "frozen seq changed on replay");
assert_eq!(a.last_write_seq, seq_active, "active seq changed on replay");
db.flush().unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let f = internal_node_record(&db, 1).unwrap().unwrap();
let a = internal_node_record(&db, 2).unwrap().unwrap();
assert_eq!(
f.last_write_seq, seq_frozen,
"frozen seq changed after flush+reopen"
);
assert_eq!(
a.last_write_seq, seq_active,
"active seq changed after flush+reopen"
);
db.close().unwrap();
}
}
#[test]
fn test_compaction_preserves_last_write_seq() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let seq1 = internal_node_record(&db, id1).unwrap().unwrap().last_write_seq;
db.flush().unwrap();
let id2 = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let seq2 = internal_node_record(&db, id2).unwrap().unwrap().last_write_seq;
db.flush().unwrap();
db.compact().unwrap();
let n1 = internal_node_record(&db, id1).unwrap().unwrap();
let n2 = internal_node_record(&db, id2).unwrap().unwrap();
assert_eq!(n1.last_write_seq, seq1);
assert_eq!(n2.last_write_seq, seq2);
db.close().unwrap();
}
#[test]
fn test_batch_ops_get_distinct_seq() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let inputs: Vec<NodeInput> = (0..5)
.map(|i| NodeInput {
labels: vec!["Person".to_string()],
key: format!("n{}", i),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
})
.collect();
let ids = db.batch_upsert_nodes(inputs).unwrap();
let seqs: Vec<u64> = ids
.iter()
.map(|&id| internal_node_record(&db, id).unwrap().unwrap().last_write_seq)
.collect();
for pair in seqs.windows(2) {
assert!(
pair[1] > pair[0],
"batch seqs must be distinct and increasing: {:?}",
seqs
);
}
db.close().unwrap();
}
#[test]
fn test_compaction_preserves_edge_last_write_seq() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let nid1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let nid2 = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = db
.upsert_edge(nid1, nid2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let edge_seq = internal_edge_record(&db, eid).unwrap().unwrap().last_write_seq;
assert!(edge_seq > 0);
db.flush().unwrap();
db.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
db.compact().unwrap();
let edge_after = internal_edge_record(&db, eid).unwrap().unwrap();
assert_eq!(edge_after.last_write_seq, edge_seq);
db.close().unwrap();
}
#[test]
fn test_get_edge_hydrates_last_write_seq_from_segment() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let nid1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let nid2 = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = db
.upsert_edge(nid1, nid2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let memtable_seq = internal_edge_record(&db, eid)
.unwrap()
.unwrap()
.last_write_seq;
assert!(
memtable_seq > 0,
"edge in memtable must have last_write_seq > 0"
);
db.flush().unwrap();
let segment_edge = internal_edge_record(&db, eid).unwrap().unwrap();
assert_eq!(
segment_edge.last_write_seq, memtable_seq,
"get_edge from segment must preserve last_write_seq (got {}, expected {})",
segment_edge.last_write_seq, memtable_seq
);
db.close().unwrap();
}
#[test]
fn test_get_nodes_batch_hydrates_last_write_seq_from_segment() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id1 = db
.upsert_node("Person", "n1", UpsertNodeOptions::default())
.unwrap();
let id2 = db
.upsert_node("Person", "n2", UpsertNodeOptions::default())
.unwrap();
let id3 = db
.upsert_node("Person", "n3", UpsertNodeOptions::default())
.unwrap();
let seq1 = internal_node_record(&db, id1).unwrap().unwrap().last_write_seq;
let seq2 = internal_node_record(&db, id2).unwrap().unwrap().last_write_seq;
let seq3 = internal_node_record(&db, id3).unwrap().unwrap().last_write_seq;
db.flush().unwrap();
let results = db.get_nodes_raw(&[id1, id2, id3]).unwrap();
assert_eq!(
results[0].as_ref().unwrap().last_write_seq,
seq1,
"batch node read from segment must preserve last_write_seq for id1"
);
assert_eq!(
results[1].as_ref().unwrap().last_write_seq,
seq2,
"batch node read from segment must preserve last_write_seq for id2"
);
assert_eq!(
results[2].as_ref().unwrap().last_write_seq,
seq3,
"batch node read from segment must preserve last_write_seq for id3"
);
db.close().unwrap();
}
#[test]
fn test_get_edges_batch_hydrates_last_write_seq_from_segment() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let nid1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let nid2 = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let nid3 = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let eid1 = db
.upsert_edge(nid1, nid2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let eid2 = db
.upsert_edge(nid2, nid3, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let eid3 = db
.upsert_edge(nid1, nid3, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let eseq1 = internal_edge_record(&db, eid1)
.unwrap()
.unwrap()
.last_write_seq;
let eseq2 = internal_edge_record(&db, eid2)
.unwrap()
.unwrap()
.last_write_seq;
let eseq3 = internal_edge_record(&db, eid3)
.unwrap()
.unwrap()
.last_write_seq;
db.flush().unwrap();
let results = internal_edge_records(&db, &[eid1, eid2, eid3]).unwrap();
assert_eq!(
results[0].as_ref().unwrap().last_write_seq,
eseq1,
"batch edge read from segment must preserve last_write_seq for eid1"
);
assert_eq!(
results[1].as_ref().unwrap().last_write_seq,
eseq2,
"batch edge read from segment must preserve last_write_seq for eid2"
);
assert_eq!(
results[2].as_ref().unwrap().last_write_seq,
eseq3,
"batch edge read from segment must preserve last_write_seq for eid3"
);
db.close().unwrap();
}
#[test]
fn test_tombstone_last_write_seq_survives_flush_reopen() {
let dir = tempfile::tempdir().unwrap();
let delete_seq;
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "doomed", UpsertNodeOptions::default())
.unwrap();
db.delete_node(id).unwrap();
delete_seq = db.engine_seq_for_test();
db.flush().unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let seg = &db.segments_for_test()[0];
let tombstones = seg.deleted_node_tombstones();
assert_eq!(tombstones.len(), 1, "should have exactly 1 node tombstone");
let entry = tombstones.values().next().unwrap();
assert!(
entry.last_write_seq > 0,
"tombstone last_write_seq must be > 0 after flush+reopen, got {}",
entry.last_write_seq
);
assert_eq!(
entry.last_write_seq, delete_seq,
"tombstone last_write_seq must match the delete op's engine_seq"
);
db.close().unwrap();
}
}
#[test]
fn test_tombstone_survives_flush_reopen() {
let dir = tempfile::tempdir().unwrap();
let deleted_id;
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
deleted_id = db
.upsert_node("Person", "doomed", UpsertNodeOptions::default())
.unwrap();
db.delete_node(deleted_id).unwrap();
db.flush().unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert_eq!(db.segment_tombstone_node_count().unwrap(), 1);
assert!(db.get_node(deleted_id).unwrap().is_none());
db.close().unwrap();
}
}
#[test]
fn test_source_list_find_node_across_segments() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "seg-node", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let node = db.get_node(id).unwrap().unwrap();
assert_eq!(node.key, "seg-node");
db.upsert_node(
"Person",
"seg-node",
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
let node = db.get_node(id).unwrap().unwrap();
assert_eq!(node.weight, 2.0);
db.close().unwrap();
}
#[test]
fn test_source_list_find_node_tombstoned_in_memtable_segment_has_record() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "will-die", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
db.delete_node(id).unwrap();
assert!(db.get_node(id).unwrap().is_none());
db.close().unwrap();
}
#[test]
fn test_source_list_find_edge_by_triple_across_segment() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(
dir.path(),
&DbOptions {
edge_uniqueness: true,
..Default::default()
},
)
.unwrap();
let n1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = db.upsert_edge(n1, n2, "RELATES_TO", Default::default()).unwrap();
db.flush().unwrap();
let edge = db.get_edge_by_triple(n1, n2, "RELATES_TO").unwrap().unwrap();
assert_eq!(edge.id, eid);
db.delete_edge(eid).unwrap();
assert!(db.get_edge_by_triple(n1, n2, "RELATES_TO").unwrap().is_none());
db.close().unwrap();
}
#[test]
fn test_source_list_find_node_by_label_key_across_segment() {
let dir = tempfile::tempdir().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "keyed", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let node = db.get_node_by_key("Person", "keyed").unwrap().unwrap();
assert_eq!(node.id, id);
db.delete_node(id).unwrap();
assert!(db.get_node_by_key("Person", "keyed").unwrap().is_none());
db.close().unwrap();
}
#[test]
fn test_freeze_creates_immutable_memtable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("freeze_test");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
assert_eq!(db.immutable_memtable_count(), 0);
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 1);
assert!(db.active_memtable().is_empty());
assert_eq!(db.active_wal_generation(), 1);
db.close().unwrap();
}
#[test]
fn test_freeze_empty_memtable_is_noop() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("freeze_empty");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 0);
assert_eq!(db.active_wal_generation(), 0);
db.close().unwrap();
}
#[test]
fn test_write_after_freeze_goes_to_new_generation() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("freeze_write");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
seed_internal_node_labels(&db, &[1]).unwrap();
let id_a = db
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let id_b = db
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
assert!(!db.active_memtable().is_empty());
let alice = db.get_node(id_a).unwrap().unwrap();
assert_eq!(alice.key, "alice");
let bob = db.get_node(id_b).unwrap().unwrap();
assert_eq!(bob.key, "bob");
let gen0 = wal_generation_path(&db_path, 0);
let gen1 = wal_generation_path(&db_path, 1);
assert!(gen0.exists(), "WAL generation 0 should exist");
assert!(gen1.exists(), "WAL generation 1 should exist");
db.sync().unwrap();
let gen0_ops = WalReader::read_generation(&db_path, 0).unwrap();
assert_eq!(gen0_ops.len(), 1);
let gen1_ops = WalReader::read_generation(&db_path, 1).unwrap();
assert_eq!(gen1_ops.len(), 1);
db.close().unwrap();
}
#[test]
fn test_flush_with_wal_generations() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("flush_gen");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
let seg_info = db.flush().unwrap();
assert!(seg_info.is_some());
assert_eq!(db.immutable_memtable_count(), 0);
let nodes = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(nodes.len(), 2);
let gen0 = wal_generation_path(&db_path, 0);
assert!(
!gen0.exists(),
"WAL generation 0 should be retired after flush"
);
db.close().unwrap();
}
#[test]
fn test_replay_multiple_wal_generations() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("replay_gen");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
let gen0 = wal_generation_path(&db_path, 0);
let gen1 = wal_generation_path(&db_path, 1);
assert!(
gen0.exists(),
"WAL generation 0 should be retained by close_fast"
);
assert!(gen1.exists(), "WAL generation 1 should exist");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let nodes = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
nodes.len(),
2,
"both nodes should be replayed from WAL generations"
);
let alice = db.get_node_by_key("Person", "alice").unwrap();
assert!(alice.is_some(), "alice should be found after WAL replay");
let bob = db.get_node_by_key("Person", "bob").unwrap();
assert!(bob.is_some(), "bob should be found after WAL replay");
db.close().unwrap();
}
}
#[test]
fn test_freeze_and_read_from_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("freeze_read");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let id_a = db
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
let id_b = db
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 1);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
assert_eq!(db.in_flight_count(), 1);
let alice = db.get_node(id_a).unwrap();
assert!(
alice.is_some(),
"alice should be readable from in-flight immutable epoch"
);
assert_eq!(alice.unwrap().key, "alice");
let bob = db.get_node(id_b).unwrap();
assert!(
bob.is_some(),
"bob should be readable from in-flight immutable epoch"
);
assert_eq!(bob.unwrap().key, "bob");
let alice_by_key = db.get_node_by_key("Person", "alice").unwrap();
assert!(
alice_by_key.is_some(),
"alice should be findable by key while in-flight"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
2,
"get_nodes_by_labels should see in-flight epoch data"
);
release_tx.send(()).unwrap();
db.wait_one_flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 1);
assert!(db.get_node(id_a).unwrap().is_some());
assert!(db.get_node(id_b).unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_multiple_freezes_before_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_freeze");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 1);
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 2);
db.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 3);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
3,
"all nodes across 3 immutable epochs should be visible"
);
assert_eq!(db.active_wal_generation(), 3);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap(); db.enqueue_one_flush().unwrap(); db.enqueue_one_flush().unwrap(); ready_rx.recv().unwrap();
assert_eq!(db.immutable_epoch_count(), 3);
assert_eq!(db.in_flight_count(), 3);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 3, "all nodes visible during in-flight flush");
release_tx.send(()).unwrap();
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 3);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 3);
db.close().unwrap();
}
#[test]
fn test_wal_generation_survives_close_fast() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("close_fast_gen");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "node1", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "node2", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 2, "data should survive close_fast + reopen");
db.close().unwrap();
}
}
#[test]
fn test_flush_retires_wal_generations() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("retire_gen");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "node1", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let gen0 = wal_generation_path(&db_path, 0);
assert!(
!gen0.exists(),
"WAL generation 0 should be retired after flush"
);
assert!(db.active_wal_generation() >= 1);
assert!(db.manifest().unwrap().pending_flush_epochs.is_empty());
db.close().unwrap();
}
#[test]
fn test_bg_flush_writes_continue_during_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bg_flush_continue");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..50 {
db.upsert_node("Person", &format!("pre:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 1);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
for i in 0..50 {
db.upsert_node("Person", &format!("post:{}", i), UpsertNodeOptions::default())
.unwrap();
}
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
100,
"all nodes should be visible during in-flight flush"
);
assert_eq!(db.in_flight_count(), 1);
release_tx.send(()).unwrap();
db.flush().unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 100, "all nodes should be visible after bg flush");
assert!(db.segment_count().unwrap() >= 2, "should have at least 2 segments");
assert_eq!(db.immutable_epoch_count(), 0);
db.close().unwrap();
}
#[test]
fn test_bg_flush_multiple_immutables() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bg_flush_multi");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for batch in 0..4 {
for i in 0..10 {
db.upsert_node(
"Person",
&format!("batch{}:node{}", batch, i),
UpsertNodeOptions::default(),
)
.unwrap();
}
db.freeze_memtable().unwrap();
}
assert_eq!(db.immutable_epoch_count(), 4);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap(); db.enqueue_one_flush().unwrap();
db.enqueue_one_flush().unwrap();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
assert_eq!(db.immutable_epoch_count(), 4);
assert_eq!(db.in_flight_count(), 4);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 40, "all 40 nodes visible during in-flight flush");
release_tx.send(()).unwrap();
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(
db.segment_count().unwrap(),
4,
"should have 4 segments from 4 frozen epochs"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 40, "all 40 nodes should be present in segments");
let path = db.path().to_path_buf();
db.close().unwrap();
let db2 = DatabaseEngine::open(&path, &DbOptions::default()).unwrap();
let all2 = db2.get_nodes_by_labels("Person").unwrap();
assert_eq!(all2.len(), 40, "all 40 nodes should survive reopen");
db2.close().unwrap();
}
#[test]
fn test_bg_flush_close_drains_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bg_flush_drain");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..20 {
db.upsert_node("Person", &format!("drain:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 20);
let path = db.path().to_path_buf();
db.close().unwrap();
let db2 = DatabaseEngine::open(&path, &DbOptions::default()).unwrap();
let all2 = db2.get_nodes_by_labels("Person").unwrap();
assert_eq!(all2.len(), 20, "all nodes should survive close + reopen");
db2.close().unwrap();
}
#[test]
fn test_bg_flush_close_fast_preserves_recovery() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bg_flush_close_fast");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..15 {
db.upsert_node("Person", &format!("fast:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
15,
"all nodes should be recovered via WAL replay after close_fast"
);
db.close().unwrap();
}
}
#[test]
fn test_bg_flush_close_fast_with_frozen_memtables() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bg_flush_close_fast_frozen");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..10 {
db.upsert_node("Person", &format!("frozen:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.freeze_memtable().unwrap();
for i in 10..20 {
db.upsert_node("Person", &format!("active:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
20,
"all nodes from frozen + active memtable should survive close_fast + reopen"
);
db.close().unwrap();
}
}
#[test]
fn test_shutdown_bg_flush_resets_stale_in_flight_epochs() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bg_flush_stale_in_flight");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "stale-in-flight", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.with_core_mut(|core| {
assert_eq!(core.immutable_epochs.len(), 1);
core.immutable_epochs[0].in_flight = true;
assert_eq!(core.in_flight_count(), 1);
let events = core.shutdown_bg_flush();
assert!(events.is_empty());
assert_eq!(core.in_flight_count(), 0);
Ok(())
})
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node = db.get_node_by_key("Person", "stale-in-flight").unwrap();
assert!(node.is_some());
db.close().unwrap();
}
}
#[test]
fn test_stale_frozen_epochs_cleaned_on_reopen_then_flush_works() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("stale_epoch");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "before_crash", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "active_at_crash", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(
db.manifest().unwrap().pending_flush_epochs.len(),
1,
"frozen epoch retained in manifest on reopen"
);
assert_eq!(db.immutable_epoch_count(), 1);
assert!(db.get_node_by_key("Person", "before_crash").unwrap().is_some());
assert!(db.get_node_by_key("Person", "active_at_crash").unwrap().is_some());
db.upsert_node("Person", "after_reopen", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "post_freeze", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
assert!(db.get_node_by_key("Person", "before_crash").unwrap().is_some());
assert!(db.get_node_by_key("Person", "active_at_crash").unwrap().is_some());
assert!(db.get_node_by_key("Person", "after_reopen").unwrap().is_some());
assert!(db.get_node_by_key("Person", "post_freeze").unwrap().is_some());
assert!(
db.manifest().unwrap().pending_flush_epochs.is_empty(),
"no stale epochs after post-recovery flush"
);
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 4, "all 4 nodes should survive full cycle");
db.close().unwrap();
}
}
#[test]
fn test_repeated_crash_after_freeze_preserves_data() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("double_crash");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "survivor", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "survivor").unwrap().is_some());
assert_eq!(db.immutable_epoch_count(), 1);
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(
db.get_node_by_key("Person", "survivor").unwrap().is_some(),
"data must survive two crashes without flush"
);
assert_eq!(db.immutable_epoch_count(), 1);
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert!(db.manifest().unwrap().pending_flush_epochs.is_empty());
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "survivor").unwrap().is_some());
db.close().unwrap();
}
}
#[test]
fn test_multi_freeze_flush_retires_each_wal_gen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_retire");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for batch in 0..3 {
db.upsert_node("Person", &format!("batch{}", batch), UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
}
assert_eq!(db.immutable_epoch_count(), 3);
assert_eq!(db.active_wal_generation(), 3);
assert!(
wal_generation_path(&db_path, 0).exists(),
"wal_0 should exist before flush"
);
assert!(
wal_generation_path(&db_path, 1).exists(),
"wal_1 should exist before flush"
);
assert!(
wal_generation_path(&db_path, 2).exists(),
"wal_2 should exist before flush"
);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap(); db.enqueue_one_flush().unwrap();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
assert!(
wal_generation_path(&db_path, 0).exists(),
"wal_0 should exist during in-flight"
);
assert!(
wal_generation_path(&db_path, 1).exists(),
"wal_1 should exist during in-flight"
);
assert!(
wal_generation_path(&db_path, 2).exists(),
"wal_2 should exist during in-flight"
);
for batch in 0..3 {
assert!(
db.get_node_by_key("Person", &format!("batch{}", batch))
.unwrap()
.is_some(),
"batch{} should be visible during in-flight",
batch
);
}
release_tx.send(()).unwrap();
db.flush().unwrap();
assert!(
!wal_generation_path(&db_path, 0).exists(),
"wal_0 should be retired after flush"
);
assert!(
!wal_generation_path(&db_path, 1).exists(),
"wal_1 should be retired after flush"
);
assert!(
!wal_generation_path(&db_path, 2).exists(),
"wal_2 should be retired after flush"
);
assert!(
wal_generation_path(&db_path, 3).exists(),
"active wal_3 should still exist"
);
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 3);
for batch in 0..3 {
assert!(
db.get_node_by_key("Person", &format!("batch{}", batch))
.unwrap()
.is_some(),
"batch{} should be visible in segments",
batch
);
}
db.close().unwrap();
}
#[test]
fn test_flush_wait_loop_handles_worker_failure() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("flush_drain");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
db.upsert_node("Person", "fail_node", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.set_flush_force_error();
db.enqueue_one_flush().unwrap();
let result = db.wait_one_flush();
assert!(result.is_err(), "worker failure should propagate as error");
assert_eq!(db.immutable_epoch_count(), 1);
assert_eq!(db.in_flight_count(), 0);
assert!(
db.get_node_by_key("Person", "fail_node").unwrap().is_some(),
"data should remain visible after worker failure"
);
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 1);
assert!(
db.get_node_by_key("Person", "fail_node").unwrap().is_some(),
"data should be in segment after retry"
);
db.close().unwrap();
}
#[test]
fn test_write_after_reported_flush_failure_retries_in_background() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("flush_retry_on_write");
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
db.upsert_node("Person", "fail_node", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.set_flush_force_error();
db.enqueue_one_flush().unwrap();
let result = db.wait_one_flush();
assert!(result.is_err(), "first failure should surface");
assert_eq!(db.immutable_epoch_count(), 1);
assert_eq!(db.in_flight_count(), 0);
db.upsert_node("Person", "retry_trigger", UpsertNodeOptions::default())
.unwrap();
assert_eq!(
db.in_flight_count(),
1,
"ordinary write should restart retry for failed epoch"
);
let seg = db.wait_one_flush().unwrap();
assert!(seg.is_some(), "retried epoch should flush successfully");
assert_eq!(db.immutable_epoch_count(), 0);
assert!(
db.get_node_by_key("Person", "fail_node").unwrap().is_some(),
"failed epoch data should be published after retry"
);
db.upsert_node("Person", "after_clear", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
assert!(db.get_node_by_key("Person", "retry_trigger").unwrap().is_some());
assert!(db.get_node_by_key("Person", "after_clear").unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_get_edges_batch_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("edges_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e2 = db
.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let results = db.get_edges(&[e1, e2]).unwrap();
assert!(
results[0].is_some(),
"edge in immutable memtable should be found by get_edges"
);
assert!(
results[1].is_some(),
"edge in active memtable should be found by get_edges"
);
assert_eq!(results[0].as_ref().unwrap().from, a);
assert_eq!(results[0].as_ref().unwrap().to, b);
assert_eq!(results[1].as_ref().unwrap().from, a);
assert_eq!(results[1].as_ref().unwrap().to, c);
db.close().unwrap();
}
#[test]
fn test_neighbors_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("nbrs_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let nbrs = db
.neighbors(
a,
&NeighborOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert_eq!(
nbrs.len(),
2,
"neighbors should see edges from both active and immutable memtables"
);
let neighbor_ids: Vec<u64> = nbrs.iter().map(|n| n.node_id).collect();
assert!(
neighbor_ids.contains(&b),
"should see neighbor in immutable"
);
assert!(neighbor_ids.contains(&c), "should see neighbor in active");
db.close().unwrap();
}
#[test]
fn test_find_nodes_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("find_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert(
"status".to_string(),
PropValue::String("active".to_string()),
);
let _id_a = db
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let _id_b = db
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
let found = db
.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap();
assert_eq!(
found.len(),
2,
"find_nodes should see nodes from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_nodes_by_labels_paged_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("label_paged_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let page = db.nodes_by_labels_paged("Person", &PageRequest::default()).unwrap();
assert_eq!(
page.items.len(),
3,
"nodes_by_labels_paged should see nodes from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_edges_by_label_paged_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("edge_label_paged_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let page = db.edges_by_label_paged("KNOWS", &PageRequest::default()).unwrap();
assert_eq!(
page.items.len(),
2,
"edges_by_label_paged should see edges from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_neighbors_batch_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("nbrs_batch_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let results = db
.neighbors_batch(
&[a],
&NeighborOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
let a_nbrs = results.get(&a).unwrap();
assert_eq!(
a_nbrs.len(),
2,
"neighbors_batch should see edges from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_dense_search_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("dense_imm");
let dense_config = DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
};
let opts = DbOptions {
dense_vector: Some(dense_config),
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
db.upsert_node(
"Person",
"vec_a",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0, 0.0]),
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"vec_b",
UpsertNodeOptions {
dense_vector: Some(vec![0.0, 1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let hits = db
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(vec![1.0, 0.0, 0.0]),
sparse_query: None,
k: 10,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap();
assert!(
hits.len() >= 2,
"dense vector search should find nodes from both active and immutable memtables, found {}",
hits.len()
);
db.close().unwrap();
}
#[test]
fn test_sparse_search_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("sparse_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node(
"Person",
"sp_a",
UpsertNodeOptions {
sparse_vector: Some(vec![(0, 1.0), (1, 0.5)]),
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"sp_b",
UpsertNodeOptions {
sparse_vector: Some(vec![(0, 0.5), (2, 1.0)]),
..Default::default()
},
)
.unwrap();
let hits = db
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Sparse,
dense_query: None,
sparse_query: Some(vec![(0, 1.0)]),
k: 10,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap();
assert!(
hits.len() >= 2,
"sparse vector search should find nodes from both active and immutable memtables, found {}",
hits.len()
);
db.close().unwrap();
}
#[test]
fn test_degree_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("degree_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let deg = db
.degree(
a,
&DegreeOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert_eq!(
deg, 2,
"degree should count edges from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_top_k_neighbors_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("topk_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
c,
"KNOWS",
UpsertEdgeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
let top = db
.top_k_neighbors(
a,
10,
&TopKOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert_eq!(
top.len(),
2,
"top_k_neighbors should see edges from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_find_nodes_by_time_range_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("time_range_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let found = db.find_nodes_by_time_range("Person", 0, i64::MAX).unwrap();
assert_eq!(
found.len(),
2,
"find_nodes_by_time_range should see nodes from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_immutable_tombstones_respected() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("imm_tombstone");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
db.delete_node(b).unwrap();
db.freeze_memtable().unwrap();
let all = db.nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
1,
"deleted node should be hidden by tombstone in immutable memtable"
);
assert_eq!(all[0], a);
let result = db.get_node(b).unwrap();
assert!(
result.is_none(),
"deleted node should not be returned from immutable memtable tombstone"
);
db.close().unwrap();
}
#[test]
fn test_multiple_immutable_memtables_newest_wins() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id_a = db
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 1.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let node = db.get_node(id_a).unwrap().unwrap();
assert!(
(node.weight - 2.0).abs() < f32::EPSILON,
"newest immutable memtable should win, got weight {}",
node.weight
);
let all = db.nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
1,
"should see 1 node not duplicated across immutables"
);
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
db.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
let found = db.nodes_by_labels("Person").unwrap();
assert_eq!(
found.len(),
2,
"should see A and B across active + immutables"
);
db.close().unwrap();
}
#[test]
fn test_multiple_immutable_tombstone_shadows_older() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_imm_tomb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "doomed", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.delete_node(id).unwrap();
db.freeze_memtable().unwrap();
assert!(
db.get_node(id).unwrap().is_none(),
"tombstone in newer immutable should shadow record in older immutable"
);
assert_eq!(db.nodes_by_labels("Person").unwrap().len(), 0);
db.close().unwrap();
}
#[test]
fn test_export_adjacency_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("export_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let export = db.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(export.node_ids.len(), 3, "export should see all 3 nodes");
let edges_from_a: Vec<_> = export.edges.iter().filter(|e| e.from == a).collect();
assert_eq!(
edges_from_a.len(),
2,
"export_adjacency should see edges from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_connected_components_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("cc_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let components = db
.connected_components(&ComponentOptions::default())
.unwrap();
let comp_a = components.get(&a).unwrap();
let comp_b = components.get(&b).unwrap();
let comp_c = components.get(&c).unwrap();
assert_eq!(comp_a, comp_b, "a and b should be in the same component");
assert_eq!(
comp_b, comp_c,
"b and c should be in the same component (edge in active, bridged via immutable)"
);
db.close().unwrap();
}
#[test]
fn test_shortest_path_through_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("sp_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let path = db
.shortest_path(a, c, &ShortestPathOptions::default())
.unwrap();
assert!(
path.is_some(),
"shortest_path should find path through immutable memtable edge"
);
let path = path.unwrap();
assert_eq!(path.nodes.len(), 3, "path should be a→b→c (3 nodes)");
assert_eq!(path.nodes[0], a);
assert_eq!(path.nodes[1], b);
assert_eq!(path.nodes[2], c);
let connected = db
.is_connected(a, c, &IsConnectedOptions::default())
.unwrap();
assert!(
connected,
"is_connected should find path through immutable memtable edge"
);
db.close().unwrap();
}
#[test]
fn test_find_nodes_paged_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("find_paged_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("role".to_string(), PropValue::String("admin".to_string()));
db.upsert_node(
"Person",
"user_a",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"user_b",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
let page = db
.find_nodes_paged("Person",
"role",
&PropValue::String("admin".to_string()),
&PageRequest::default(),
)
.unwrap();
assert_eq!(
page.items.len(),
2,
"find_nodes_paged should see nodes from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_upsert_node_dedup_across_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("upsert_dedup_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id1 = db
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let id2 = db
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
assert_eq!(
id1, id2,
"upsert_node must reuse existing ID from immutable memtable"
);
let node = db.get_node(id1).unwrap().unwrap();
assert!(
(node.weight - 0.9).abs() < f32::EPSILON,
"upsert should update properties"
);
db.close().unwrap();
}
#[test]
fn test_edge_uniqueness_across_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("edge_uniq_imm");
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let e2 = db
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
weight: 0.7,
..Default::default()
},
)
.unwrap();
assert_eq!(
e1, e2,
"edge uniqueness must find existing edge in immutable memtable"
);
let edge = db.get_edge(e2).unwrap().unwrap();
assert!(
(edge.weight - 0.7).abs() < f32::EPSILON,
"edge upsert should update properties"
);
db.close().unwrap();
}
#[test]
fn test_batch_upsert_node_dedup_across_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("batch_dedup_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id_alice = db
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let inputs = vec![
NodeInput {
labels: vec!["Person".to_string()],
key: "alice".to_string(),
props: BTreeMap::new(),
weight: 0.8,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "bob".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
];
let ids = db.batch_upsert_nodes(inputs).unwrap();
assert_eq!(
ids[0], id_alice,
"batch_upsert_nodes must reuse existing ID from immutable memtable"
);
assert_ne!(ids[1], id_alice, "new node must get a new ID");
db.close().unwrap();
}
#[test]
fn test_batch_upsert_edge_uniqueness_across_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("batch_edge_uniq_imm");
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let inputs = vec![
EdgeInput {
from: a,
to: b,
label: "KNOWS".to_string(),
props: BTreeMap::new(),
weight: 0.5,
valid_from: None,
valid_to: None,
},
EdgeInput {
from: a,
to: c,
label: "KNOWS".to_string(),
props: BTreeMap::new(),
weight: 1.0,
valid_from: None,
valid_to: None,
},
];
let ids = db.batch_upsert_edges(inputs).unwrap();
assert_eq!(
ids[0], e1,
"batch_upsert_edges must reuse existing edge ID from immutable memtable"
);
assert_ne!(ids[1], e1, "new edge must get a new ID");
db.close().unwrap();
}
#[test]
fn test_delete_node_cascades_immutable_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("del_cascade_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let e2 = db
.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.delete_node(a).unwrap();
assert!(
db.get_node(a).unwrap().is_none(),
"deleted node should be gone"
);
assert!(
db.get_edge(e1).unwrap().is_none(),
"edge in immutable memtable should be cascade-deleted"
);
assert!(
db.get_edge(e2).unwrap().is_none(),
"edge in active memtable should be cascade-deleted"
);
assert!(db.get_node(b).unwrap().is_some());
assert!(db.get_node(c).unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_invalidate_edge_in_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("inv_edge_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let result = db.invalidate_edge(e1, 1000).unwrap();
assert!(
result.is_some(),
"invalidate_edge must find edge in immutable memtable"
);
let updated = result.unwrap();
assert_eq!(updated.valid_to, 1000);
db.close().unwrap();
}
#[test]
fn test_graph_patch_dedup_across_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("patch_imm");
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let a_id = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b_id = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a_id, b_id, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let patch = GraphPatch {
upsert_nodes: vec![
NodeInput {
labels: vec!["Person".to_string()],
key: "a".to_string(),
props: BTreeMap::new(),
weight: 0.5,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "c".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
],
upsert_edges: vec![EdgeInput {
from: a_id,
to: b_id,
label: "KNOWS".to_string(),
props: BTreeMap::new(),
weight: 0.3,
valid_from: None,
valid_to: None,
}],
invalidate_edges: vec![],
delete_node_ids: vec![],
delete_edge_ids: vec![],
};
let result = db.graph_patch(patch).unwrap();
assert_eq!(
result.node_ids[0], a_id,
"graph_patch must reuse node ID from immutable memtable"
);
assert_ne!(result.node_ids[1], a_id, "new node must get a new ID");
assert_eq!(
result.edge_ids[0], e1,
"graph_patch must reuse edge ID from immutable memtable"
);
db.close().unwrap();
}
#[test]
fn test_graph_patch_delete_cascades_immutable_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("patch_del_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let patch = GraphPatch {
upsert_nodes: vec![],
upsert_edges: vec![],
invalidate_edges: vec![],
delete_node_ids: vec![a],
delete_edge_ids: vec![],
};
db.graph_patch(patch).unwrap();
assert!(
db.get_node(a).unwrap().is_none(),
"deleted node should be gone"
);
assert!(
db.get_edge(e1).unwrap().is_none(),
"cascaded edge from immutable memtable should be deleted"
);
db.close().unwrap();
}
#[test]
fn test_prune_finds_targets_in_immutable_memtable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("prune_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node(
"Person",
"old_low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"new_high",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
let result = db
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(
result.nodes_pruned, 1,
"prune must find and delete the node in the immutable memtable"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 1, "only the high-weight node should survive");
assert_eq!(all[0].key, "new_high");
db.close().unwrap();
}
#[test]
fn test_prune_respects_tombstones_in_immutable_memtable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("prune_ts_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id = db
.upsert_node(
"Person",
"target",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
db.delete_node(id).unwrap();
db.freeze_memtable().unwrap();
let result = db
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(
result.nodes_pruned, 0,
"prune must respect tombstones from immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_id_allocation_stable_across_freeze() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("id_alloc_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let d = db
.upsert_node("Person", "d", UpsertNodeOptions::default())
.unwrap();
assert!(
c > b,
"node ID after freeze must be greater than pre-freeze IDs: c={} b={}",
c,
b
);
assert!(d > c, "node IDs must be monotonically increasing");
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let e2 = db
.upsert_edge(c, d, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
assert!(
e2 > e1,
"edge ID after freeze must be greater than pre-freeze IDs"
);
assert_eq!(db.get_nodes_by_labels("Person").unwrap().len(), 4);
db.close().unwrap();
}
#[test]
fn test_traversal_sees_immutable_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("traverse_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let hits = db
.traverse(
a,
2,
&TraverseOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
let found_ids: Vec<u64> = hits.items.iter().map(|h| h.node_id).collect();
assert!(
found_ids.contains(&b),
"traversal should find node at depth 1 via immutable memtable edge"
);
assert!(
found_ids.contains(&c),
"traversal should find node at depth 2 via immutable memtable edges"
);
db.close().unwrap();
}
#[test]
fn test_graph_patch_invalidate_edge_in_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("patch_inv_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let patch = GraphPatch {
upsert_nodes: vec![],
upsert_edges: vec![],
invalidate_edges: vec![(e1, 500)],
delete_node_ids: vec![],
delete_edge_ids: vec![],
};
db.graph_patch(patch).unwrap();
let edge = db.get_edge(e1).unwrap().unwrap();
assert_eq!(edge.valid_to, 500);
db.close().unwrap();
}
#[test]
fn test_dedup_across_active_immutable_and_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("three_tier_dedup");
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let seg_edge = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let imm_edge = db
.upsert_edge(a, c, "REPORTS_TO", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let d = db
.upsert_node("Person", "d", UpsertNodeOptions::default())
.unwrap();
let act_edge = db
.upsert_edge(a, d, "RATES", UpsertEdgeOptions::default())
.unwrap();
let a2 = db
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.99,
..Default::default()
},
)
.unwrap();
assert_eq!(a, a2, "must reuse ID from segment");
let imm_edge2 = db
.upsert_edge(a, c, "REPORTS_TO", UpsertEdgeOptions::default())
.unwrap();
assert_eq!(
imm_edge, imm_edge2,
"must reuse edge ID from immutable memtable"
);
let seg_edge2 = db
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
assert_eq!(seg_edge, seg_edge2, "must reuse edge ID from segment");
assert_eq!(db.get_nodes_by_labels("Person").unwrap().len(), 4);
let nbrs = db
.neighbors(
a,
&NeighborOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert_eq!(
nbrs.len(),
3,
"should see edges from segment, immutable, and active"
);
db.delete_node(a).unwrap();
assert!(db.get_edge(seg_edge).unwrap().is_none());
assert!(db.get_edge(imm_edge).unwrap().is_none());
assert!(db.get_edge(act_edge).unwrap().is_none());
db.close().unwrap();
}
#[test]
fn test_write_dedup_across_multiple_immutables() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_imm_dedup");
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let id_alice = db
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
let id_bob = db
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
let e1 = db
.upsert_edge(id_alice, id_bob, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let id_charlie = db
.upsert_node("Person", "charlie", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let inputs = vec![
NodeInput {
labels: vec!["Person".to_string()],
key: "alice".to_string(),
props: BTreeMap::new(),
weight: 0.8,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "bob".to_string(),
props: BTreeMap::new(),
weight: 0.9,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "charlie".to_string(),
props: BTreeMap::new(),
weight: 0.7,
dense_vector: None,
sparse_vector: None,
},
];
let ids = db.batch_upsert_nodes(inputs).unwrap();
assert_eq!(
ids[0], id_alice,
"alice should reuse ID from older immutable"
);
assert_eq!(ids[1], id_bob, "bob should reuse ID from older immutable");
assert_eq!(
ids[2], id_charlie,
"charlie should reuse ID from newer immutable"
);
let e2 = db
.upsert_edge(id_alice, id_bob, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
assert_eq!(e1, e2, "edge should reuse ID from older immutable");
assert_eq!(db.get_nodes_by_labels("Person").unwrap().len(), 3);
db.close().unwrap();
}
#[test]
fn test_degrees_batch_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("deg_batch_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let degs = db
.degrees(
&[a, b],
&DegreeOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert_eq!(
*degs.get(&a).unwrap_or(&0),
2,
"degrees batch must count edges from both active and immutable memtables"
);
assert_eq!(*degs.get(&b).unwrap_or(&0), 0);
db.close().unwrap();
}
#[test]
fn test_sum_edge_weights_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("sum_wt_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
c,
"KNOWS",
UpsertEdgeOptions {
weight: 1.5,
..Default::default()
},
)
.unwrap();
let sum = db
.sum_edge_weights(
a,
&DegreeOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert!(
(sum - 2.0).abs() < f64::EPSILON,
"sum_edge_weights must include edges from immutable memtable, got {}",
sum
);
let avg = db
.avg_edge_weight(
a,
&DegreeOptions {
direction: Direction::Outgoing,
..Default::default()
},
)
.unwrap();
assert!(
(avg.unwrap() - 1.0).abs() < f64::EPSILON,
"avg_edge_weight must include edges from immutable memtable, got {:?}",
avg
);
db.close().unwrap();
}
#[test]
fn test_neighbors_paged_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("nbrs_paged_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let page = db
.neighbors_paged(
a,
&NeighborOptions {
direction: Direction::Outgoing,
..Default::default()
},
&PageRequest::default(),
)
.unwrap();
assert_eq!(
page.items.len(),
2,
"neighbors_paged must see edges from both active and immutable memtables"
);
db.close().unwrap();
}
#[test]
fn test_dense_search_tombstone_in_immutable_hides_result() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("dense_tomb_imm");
let dense_config = DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
};
let opts = DbOptions {
dense_vector: Some(dense_config),
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let id_a = db
.upsert_node(
"Person",
"vec_a",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0, 0.0]),
..Default::default()
},
)
.unwrap();
let id_b = db
.upsert_node(
"Person",
"vec_b",
UpsertNodeOptions {
dense_vector: Some(vec![0.9, 0.1, 0.0]),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
db.delete_node(id_a).unwrap();
db.freeze_memtable().unwrap();
let hits = db
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(vec![1.0, 0.0, 0.0]),
sparse_query: None,
k: 10,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap();
let hit_ids: Vec<u64> = hits.iter().map(|h| h.node_id).collect();
assert!(
!hit_ids.contains(&id_a),
"deleted node must not appear in dense search results (tombstone in immutable)"
);
assert!(
hit_ids.contains(&id_b),
"non-deleted node must still appear in dense search results"
);
db.close().unwrap();
}
#[test]
fn test_sparse_search_tombstone_in_immutable_hides_result() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("sparse_tomb_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id_a = db
.upsert_node(
"Person",
"sp_a",
UpsertNodeOptions {
sparse_vector: Some(vec![(0, 1.0), (1, 0.5)]),
..Default::default()
},
)
.unwrap();
let id_b = db
.upsert_node(
"Person",
"sp_b",
UpsertNodeOptions {
sparse_vector: Some(vec![(0, 0.8), (2, 1.0)]),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
db.delete_node(id_a).unwrap();
db.freeze_memtable().unwrap();
let hits = db
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Sparse,
dense_query: None,
sparse_query: Some(vec![(0, 1.0)]),
k: 10,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap();
let hit_ids: Vec<u64> = hits.iter().map(|h| h.node_id).collect();
assert!(
!hit_ids.contains(&id_a),
"deleted node must not appear in sparse search results (tombstone in immutable)"
);
assert!(
hit_ids.contains(&id_b),
"non-deleted node must still appear in sparse search results"
);
db.close().unwrap();
}
#[test]
fn test_dense_scoped_search_sees_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("dense_scoped_imm");
let dense_config = DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
};
let opts = DbOptions {
dense_vector: Some(dense_config),
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let hub = db
.upsert_node(
"Person",
"hub",
UpsertNodeOptions {
dense_vector: Some(vec![0.5, 0.5, 0.0]),
..Default::default()
},
)
.unwrap();
let id_a = db
.upsert_node(
"Person",
"vec_a",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0, 0.0]),
..Default::default()
},
)
.unwrap();
db.upsert_edge(hub, id_a, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let id_b = db
.upsert_node(
"Person",
"vec_b",
UpsertNodeOptions {
dense_vector: Some(vec![0.0, 1.0, 0.0]),
..Default::default()
},
)
.unwrap();
db.upsert_edge(hub, id_b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let hits = db
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(vec![1.0, 0.0, 0.0]),
sparse_query: None,
k: 10,
label_filter: None,
ef_search: None,
scope: Some(VectorSearchScope {
start_node_id: hub,
max_depth: 1,
direction: Direction::Outgoing,
edge_label_filter: None,
at_epoch: None,
}),
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap();
let hit_ids: Vec<u64> = hits.iter().map(|h| h.node_id).collect();
assert!(
hit_ids.contains(&id_a),
"scoped dense search must find node from immutable memtable via traversal scope"
);
assert!(
hit_ids.contains(&id_b),
"scoped dense search must find node from active memtable via traversal scope"
);
db.close().unwrap();
}
#[test]
fn test_dijkstra_shortest_path_through_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("dijkstra_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
weight: 1.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
b,
c,
"KNOWS",
UpsertEdgeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
let path = db
.shortest_path(
a,
c,
&ShortestPathOptions {
weight_field: Some("weight".to_string()),
..Default::default()
},
)
.unwrap();
assert!(
path.is_some(),
"Dijkstra shortest_path must find path through immutable memtable edge"
);
let path = path.unwrap();
assert_eq!(path.nodes.len(), 3, "path should be a→b→c");
assert_eq!(path.nodes[0], a);
assert_eq!(path.nodes[1], b);
assert_eq!(path.nodes[2], c);
db.close().unwrap();
}
#[test]
fn test_all_shortest_paths_through_immutable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("all_sp_imm");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let paths = db
.all_shortest_paths(a, c, &AllShortestPathsOptions::default())
.unwrap();
assert!(
!paths.is_empty(),
"all_shortest_paths (BFS) must find path through immutable memtable edge"
);
assert_eq!(paths[0].nodes.len(), 3);
let paths = db
.all_shortest_paths(
a,
c,
&AllShortestPathsOptions {
weight_field: Some("weight".to_string()),
..Default::default()
},
)
.unwrap();
assert!(
!paths.is_empty(),
"all_shortest_paths (Dijkstra) must find path through immutable memtable edge"
);
assert_eq!(paths[0].nodes.len(), 3);
db.close().unwrap();
}
#[test]
fn test_crash_after_freeze_before_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_freeze");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id_a = db
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(id_a, id_a, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 1);
assert_eq!(db.active_wal_generation(), 1);
let id_b = db
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
assert_ne!(id_a, id_b);
db.close_fast().unwrap();
}
let gen0 = wal_generation_path(&db_path, 0);
let gen1 = wal_generation_path(&db_path, 1);
assert!(gen0.exists(), "WAL gen 0 should be retained");
assert!(gen1.exists(), "WAL gen 1 should be retained");
let manifest = load_manifest(&db_path).unwrap().unwrap();
assert!(
manifest
.pending_flush_epochs
.iter()
.any(|e| e.state == FlushEpochState::FrozenPendingFlush),
"manifest should record FrozenPendingFlush epoch"
);
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let alice = db.get_node_by_key("Person", "alice").unwrap();
assert!(alice.is_some(), "alice from gen 0 should be recovered");
let bob = db.get_node_by_key("Person", "bob").unwrap();
assert!(bob.is_some(), "bob from gen 1 should be recovered");
let all_nodes = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all_nodes.len(), 2, "both nodes should be present");
db.close().unwrap();
}
}
#[test]
fn test_crash_with_flushed_segment_and_unflushed_wal() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_seg_manifest");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "flushed_1", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "flushed_2", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
assert!(db.segment_count().unwrap() >= 1);
db.upsert_node("Person", "unflushed_1", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "unflushed_2", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let f1 = db.get_node_by_key("Person", "flushed_1").unwrap();
assert!(f1.is_some(), "flushed_1 from segment should be present");
let f2 = db.get_node_by_key("Person", "flushed_2").unwrap();
assert!(f2.is_some(), "flushed_2 from segment should be present");
let u1 = db.get_node_by_key("Person", "unflushed_1").unwrap();
assert!(u1.is_some(), "unflushed_1 should be recovered from WAL");
let u2 = db.get_node_by_key("Person", "unflushed_2").unwrap();
assert!(u2.is_some(), "unflushed_2 should be recovered from WAL");
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 4, "all 4 nodes should be present");
db.close().unwrap();
}
}
#[test]
fn test_crash_after_segment_write_before_manifest_publish() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_boundary2");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.close_fast().unwrap();
}
let orphan_seg = segment_dir(&db_path, 9999);
std::fs::create_dir_all(&orphan_seg).unwrap();
std::fs::write(
orphan_seg.join(crate::segment_components::PACKED_CORE_FILENAME),
b"dummy",
)
.unwrap();
let manifest = load_manifest(&db_path).unwrap().unwrap();
assert!(manifest
.pending_flush_epochs
.iter()
.any(|e| e.state == FlushEpochState::FrozenPendingFlush));
assert!(
manifest.segments.is_empty(),
"manifest should NOT reference any segment"
);
assert!(orphan_seg.exists(), "orphan segment should exist on disk");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(
db.get_node_by_key("Person", "alice").unwrap().is_some(),
"alice recovered from WAL"
);
assert!(
db.get_node_by_key("Person", "bob").unwrap().is_some(),
"bob recovered from WAL"
);
assert_eq!(db.get_nodes_by_labels("Person").unwrap().len(), 2);
assert!(
!orphan_seg.exists(),
"orphan segment should be cleaned up on reopen"
);
db.close().unwrap();
}
}
#[test]
fn test_crash_with_multiple_frozen_generations() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_multi_frozen");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "gen0_node", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "gen1_node", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "gen2_node", UpsertNodeOptions::default())
.unwrap();
assert_eq!(db.immutable_memtable_count(), 2);
assert_eq!(db.active_wal_generation(), 2);
db.close_fast().unwrap();
}
assert!(wal_generation_path(&db_path, 0).exists());
assert!(wal_generation_path(&db_path, 1).exists());
assert!(wal_generation_path(&db_path, 2).exists());
let manifest = load_manifest(&db_path).unwrap().unwrap();
let frozen_count = manifest
.pending_flush_epochs
.iter()
.filter(|e| e.state == FlushEpochState::FrozenPendingFlush)
.count();
assert_eq!(frozen_count, 2, "should have 2 FrozenPendingFlush epochs");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(
db.get_node_by_key("Person", "gen0_node").unwrap().is_some(),
"gen0 data recovered"
);
assert!(
db.get_node_by_key("Person", "gen1_node").unwrap().is_some(),
"gen1 data recovered"
);
assert!(
db.get_node_by_key("Person", "gen2_node").unwrap().is_some(),
"gen2 data recovered"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 3, "all 3 generations of data should be present");
db.close().unwrap();
}
}
#[test]
fn test_crash_after_publish_before_wal_retire() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_publish_retire");
let node_id;
let seg_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
node_id = db
.upsert_node("Person", "published_node", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
seg_id = db.manifest().unwrap().segments[0].id;
db.close().unwrap();
}
{
let mut manifest = load_manifest(&db_path).unwrap().unwrap();
manifest.pending_flush_epochs.push(FlushEpochMeta {
epoch_id: 0,
wal_generation_id: 0,
state: FlushEpochState::PublishedPendingRetire,
segment_id: Some(seg_id),
});
write_manifest(&db_path, &manifest).unwrap();
let mut writer = WalWriter::open_generation(&db_path, 0).unwrap();
let node = NodeRecord {
id: node_id,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "published_node".to_string(),
props: BTreeMap::new(),
created_at: 1000,
updated_at: 1001,
weight: 0.5,
dense_vector: None,
sparse_vector: None,
last_write_seq: 1,
};
writer.append(&WalOp::UpsertNode(node), 1).unwrap();
writer.sync().unwrap();
}
assert!(
wal_generation_path(&db_path, 0).exists(),
"WAL gen 0 should exist before reopen"
);
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node = db.get_node(node_id).unwrap();
assert!(node.is_some(), "node should be readable from segment");
assert_eq!(node.unwrap().key, "published_node");
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
1,
"should have exactly 1 node (no duplicates from WAL replay)"
);
assert!(
db.manifest().unwrap().pending_flush_epochs.is_empty(),
"pending flush epochs should be empty after cleanup"
);
assert!(
!wal_generation_path(&db_path, 0).exists(),
"WAL gen 0 should be cleaned up after reopen"
);
db.close().unwrap();
}
}
#[test]
fn test_reopen_fails_if_published_pending_retire_segment_is_missing() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_publish_missing_segment");
let node_id;
let seg_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
node_id = db
.upsert_node("Person", "published_node", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
seg_id = db.manifest().unwrap().segments[0].id;
db.close().unwrap();
}
{
let mut manifest = load_manifest(&db_path).unwrap().unwrap();
manifest.pending_flush_epochs.push(FlushEpochMeta {
epoch_id: 0,
wal_generation_id: 0,
state: FlushEpochState::PublishedPendingRetire,
segment_id: Some(seg_id),
});
write_manifest(&db_path, &manifest).unwrap();
let mut writer = WalWriter::open_generation(&db_path, 0).unwrap();
let node = NodeRecord {
id: node_id,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "published_node".to_string(),
props: BTreeMap::new(),
created_at: 1000,
updated_at: 1001,
weight: 0.5,
dense_vector: None,
sparse_vector: None,
last_write_seq: 1,
};
writer.append(&WalOp::UpsertNode(node), 1).unwrap();
writer.sync().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
std::fs::remove_dir_all(&seg_dir).unwrap();
assert!(
wal_generation_path(&db_path, 0).exists(),
"WAL gen 0 should exist before reopen"
);
let err = match DatabaseEngine::open(&db_path, &DbOptions::default()) {
Ok(_) => panic!("reopen should fail when PublishedPendingRetire segment is missing"),
Err(err) => err,
};
let msg = err.to_string();
assert!(
msg.contains("published segment") || msg.contains("PublishedPendingRetire"),
"unexpected reopen error: {}",
msg
);
assert!(
wal_generation_path(&db_path, 0).exists(),
"WAL gen 0 must remain on disk when reopen fails to verify published segment"
);
let manifest = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert!(
manifest.pending_flush_epochs.iter().any(|e| {
e.state == FlushEpochState::PublishedPendingRetire
&& e.wal_generation_id == 0
&& e.segment_id == Some(seg_id)
}),
"manifest should retain the PublishedPendingRetire epoch on failed reopen"
);
}
#[test]
fn test_crash_after_wal_delete_before_epoch_removal() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_wal_deleted");
let seg_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "survivor", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
seg_id = db.manifest().unwrap().segments[0].id;
db.close().unwrap();
}
{
let mut manifest = load_manifest(&db_path).unwrap().unwrap();
manifest.pending_flush_epochs.push(FlushEpochMeta {
epoch_id: 0,
wal_generation_id: 0,
state: FlushEpochState::PublishedPendingRetire,
segment_id: Some(seg_id),
});
write_manifest(&db_path, &manifest).unwrap();
}
assert!(
!wal_generation_path(&db_path, 0).exists(),
"WAL gen 0 should not exist (simulating already-deleted)"
);
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node = db.get_node_by_key("Person", "survivor").unwrap();
assert!(node.is_some(), "node should be readable from segment");
assert!(
db.manifest().unwrap().pending_flush_epochs.is_empty(),
"stale PublishedPendingRetire epoch should be cleaned up even without WAL file"
);
db.close().unwrap();
}
}
#[test]
fn test_orphan_segment_ignored_on_reopen_cp8() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("orphan_seg_cp8");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "real_node", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
assert_eq!(db.segment_count().unwrap(), 1);
db.close().unwrap();
}
let orphan_path = segment_dir(&db_path, 9999);
std::fs::create_dir_all(&orphan_path).unwrap();
std::fs::write(orphan_path.join("dummy.dat"), b"orphan data").unwrap();
assert!(orphan_path.exists(), "orphan segment dir should exist");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(
!orphan_path.exists(),
"orphan segment directory should be cleaned up on reopen"
);
assert_eq!(db.segment_count().unwrap(), 1);
let node = db.get_node_by_key("Person", "real_node").unwrap();
assert!(node.is_some(), "real node should still be readable");
db.close().unwrap();
}
}
#[test]
fn test_orphan_wal_generation_ignored() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("orphan_wal");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "real_node", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let mut writer = WalWriter::open_generation(&db_path, 99).unwrap();
let orphan_node = NodeRecord {
id: 999,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "orphan_ghost".to_string(),
props: BTreeMap::new(),
created_at: 5000,
updated_at: 5001,
weight: 0.5,
dense_vector: None,
sparse_vector: None,
last_write_seq: 99,
};
writer.append(&WalOp::UpsertNode(orphan_node), 99).unwrap();
writer.sync().unwrap();
}
assert!(
wal_generation_path(&db_path, 99).exists(),
"orphan WAL gen 99 should exist"
);
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let real = db.get_node_by_key("Person", "real_node").unwrap();
assert!(real.is_some(), "real_node should be recovered");
let orphan = db.get_node(999).unwrap();
assert!(
orphan.is_none(),
"orphan ghost node from unreferenced WAL gen 99 should NOT be replayed"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 1, "only real_node should exist");
assert!(
!wal_generation_path(&db_path, 99).exists(),
"orphan WAL gen 99 should be cleaned up on reopen"
);
db.close().unwrap();
}
}
#[test]
fn test_reopen_replays_frozen_epochs_oldest_first() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("replay_order");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 1.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 3.0,
..Default::default()
},
)
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node = db.get_node_by_key("Person", "shared").unwrap();
assert!(node.is_some(), "shared node should be recovered");
let node = node.unwrap();
assert!(
(node.weight - 3.0_f32).abs() < f32::EPSILON,
"newest write (weight=3.0) should win, got {}",
node.weight
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 1, "upsert dedup should produce exactly 1 node");
db.close().unwrap();
}
}
#[test]
fn test_published_pending_retire_not_replayed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("no_double_replay");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let ids: Vec<u64> = (0..5)
.map(|i| {
db.upsert_node("Person", &format!("node_{}", i), UpsertNodeOptions::default())
.unwrap()
})
.collect();
let _edge_id = db
.upsert_edge(ids[0], ids[1], "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
assert!(db.segment_count().unwrap() >= 1);
db.upsert_node("Person", "post_flush_node", UpsertNodeOptions::default())
.unwrap();
db.close().unwrap();
}
let mut manifest = load_manifest(&db_path).unwrap().unwrap();
let seg_id = manifest.segments[0].id;
manifest.pending_flush_epochs.push(FlushEpochMeta {
epoch_id: 0,
wal_generation_id: 0,
state: FlushEpochState::PublishedPendingRetire,
segment_id: Some(seg_id),
});
write_manifest(&db_path, &manifest).unwrap();
{
let mut writer = WalWriter::open_generation(&db_path, 0).unwrap();
for i in 0..5 {
let node = NodeRecord {
id: i + 1,
label_ids: NodeLabelSet::single(1).unwrap(),
key: format!("node_{}", i),
props: BTreeMap::new(),
created_at: 1000,
updated_at: 1001,
weight: 0.5,
dense_vector: None,
sparse_vector: None,
last_write_seq: i + 1,
};
writer.append(&WalOp::UpsertNode(node), i + 1).unwrap();
}
writer.sync().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
6,
"should have 6 nodes (5 from segment + 1 post-flush), not more from double replay"
);
assert!(
db.manifest().unwrap().pending_flush_epochs.is_empty(),
"PublishedPendingRetire epoch should be cleaned up"
);
assert!(
!wal_generation_path(&db_path, 0).exists(),
"WAL gen 0 should be removed after PublishedPendingRetire cleanup"
);
db.close().unwrap();
}
}
#[test]
fn test_reopen_after_flush_then_more_writes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("flush_then_write");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..10 {
db.upsert_node("Person", &format!("flushed_{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
for i in 0..10 {
db.upsert_node("Person", &format!("unflushed_{}", i), UpsertNodeOptions::default())
.unwrap();
}
let flushed_node = db.get_node_by_key("Person", "flushed_0").unwrap().unwrap();
let unflushed_node = db.get_node_by_key("Person", "unflushed_0").unwrap().unwrap();
db.upsert_edge(
flushed_node.id,
unflushed_node.id,
"KNOWS",
UpsertEdgeOptions::default(),
)
.unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..10 {
let key = format!("flushed_{}", i);
assert!(
db.get_node_by_key("Person", &key).unwrap().is_some(),
"{} should be present from segment",
key
);
}
for i in 0..10 {
let key = format!("unflushed_{}", i);
assert!(
db.get_node_by_key("Person", &key).unwrap().is_some(),
"{} should be present from WAL replay",
key
);
}
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 20, "all 20 nodes should be present");
db.close().unwrap();
}
}
#[test]
fn test_multiple_flush_reopen_cycles() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_flush_cycle");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..5 {
db.upsert_node("Person", &format!("cycle1_{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
for i in 0..5 {
db.upsert_node("Person", &format!("cycle2_{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
for i in 0..5 {
db.upsert_node("Person", &format!("cycle3_{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for cycle in 1..=3 {
for i in 0..5 {
let key = format!("cycle{}_{}", cycle, i);
assert!(
db.get_node_by_key("Person", &key).unwrap().is_some(),
"{} should be present",
key
);
}
}
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
15,
"all 15 nodes across 3 cycles should be present"
);
db.close().unwrap();
}
}
#[test]
fn test_close_fast_then_close_normally() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("fast_then_normal");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "surviving_node", UpsertNodeOptions::default())
.unwrap();
let a = db
.upsert_node("Person", "node_a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "node_b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "active_node", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "surviving_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "node_a").unwrap().is_some());
assert!(db.get_node_by_key("Person", "node_b").unwrap().is_some());
assert!(db.get_node_by_key("Person", "active_node").unwrap().is_some());
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 4, "all 4 nodes should be present after recovery");
db.flush().unwrap();
assert_eq!(db.immutable_memtable_count(), 0);
assert!(db.segment_count().unwrap() >= 1, "segments should exist after flush");
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "surviving_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "node_a").unwrap().is_some());
assert!(db.get_node_by_key("Person", "node_b").unwrap().is_some());
assert!(db.get_node_by_key("Person", "active_node").unwrap().is_some());
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 4);
assert!(
db.manifest().unwrap().pending_flush_epochs.is_empty(),
"no pending epochs after clean flush + close"
);
db.close().unwrap();
}
}
#[test]
fn test_crash_recovery_preserves_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_edges");
let node_a;
let node_b;
let node_c;
let edge_ab;
let edge_bc;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
node_a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
node_b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
edge_ab = db
.upsert_edge(node_a, node_b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
node_c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
edge_bc = db
.upsert_edge(node_b, node_c, "REPORTS_TO", UpsertEdgeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(
db.get_node(node_a).unwrap().is_some(),
"node_a should be present"
);
assert!(
db.get_node(node_b).unwrap().is_some(),
"node_b should be present"
);
assert!(
db.get_node(node_c).unwrap().is_some(),
"node_c should be present"
);
let e_ab = db.get_edge(edge_ab).unwrap();
assert!(e_ab.is_some(), "edge a->b should be present");
let e_ab = e_ab.unwrap();
assert_eq!(e_ab.from, node_a);
assert_eq!(e_ab.to, node_b);
let e_bc = db.get_edge(edge_bc).unwrap();
assert!(e_bc.is_some(), "edge b->c should be present");
let e_bc = e_bc.unwrap();
assert_eq!(e_bc.from, node_b);
assert_eq!(e_bc.to, node_c);
db.close().unwrap();
}
}
#[test]
fn test_crash_recovery_preserves_deletes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("crash_deletes");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node_a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let node_b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let _edge = db
.upsert_edge(node_a, node_b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.delete_node(node_a).unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(
db.get_node_by_key("Person", "a").unwrap().is_none(),
"deleted node_a should not be visible after recovery"
);
assert!(
db.get_node_by_key("Person", "b").unwrap().is_some(),
"non-deleted node_b should survive recovery"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 1, "only node_b should be visible");
db.close().unwrap();
}
}
#[test]
fn test_reopen_engine_seq_continuity() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("seq_continuity");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "d", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "post_crash", UpsertNodeOptions::default())
.unwrap();
assert!(db.get_node_by_key("Person", "post_crash").unwrap().is_some());
assert!(db.get_node_by_key("Person", "a").unwrap().is_some());
assert!(db.get_node_by_key("Person", "b").unwrap().is_some());
assert!(db.get_node_by_key("Person", "c").unwrap().is_some());
assert!(db.get_node_by_key("Person", "d").unwrap().is_some());
db.close().unwrap();
}
}
#[test]
fn test_repeated_crash_reopen_cycles() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("multi_crash");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "cycle1_node", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "cycle1_node").unwrap().is_some());
db.upsert_node("Person", "cycle2_node", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "cycle2_active", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "cycle1_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "cycle2_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "cycle2_active").unwrap().is_some());
db.upsert_node("Person", "cycle3_node", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
db.upsert_node("Person", "cycle3_unflushed", UpsertNodeOptions::default())
.unwrap();
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(db.get_node_by_key("Person", "cycle1_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "cycle2_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "cycle2_active").unwrap().is_some());
assert!(db.get_node_by_key("Person", "cycle3_node").unwrap().is_some());
assert!(db.get_node_by_key("Person", "cycle3_unflushed").unwrap().is_some());
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
5,
"all 5 nodes across 3 crash cycles should be present"
);
db.close().unwrap();
}
}
#[test]
fn test_backpressure_triggers_on_total_bytes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bp_total");
let opts = DbOptions {
memtable_flush_threshold: 350, memtable_hard_cap_bytes: 0, max_immutable_memtables: 0, compact_after_n_flushes: 0, wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
db.upsert_node("Person", "frozen", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 1);
assert_eq!(db.segment_count().unwrap(), 0);
db.upsert_node("Person", "active", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
assert!(
db.segment_count().unwrap() >= 1,
"auto-flush should trigger when total memtable bytes exceed soft threshold"
);
assert!(db.find_existing_node(1, "frozen").unwrap().is_some());
assert!(db.find_existing_node(1, "active").unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_max_immutable_memtables_blocks() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("max_imm");
let opts = DbOptions {
memtable_flush_threshold: 0, memtable_hard_cap_bytes: 0, max_immutable_memtables: 2, compact_after_n_flushes: 0, wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..5 {
db.upsert_node("Person", &format!("g1:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 1);
assert_eq!(db.segment_count().unwrap(), 0);
for i in 0..5 {
db.upsert_node("Person", &format!("g2:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 2);
assert_eq!(db.segment_count().unwrap(), 0);
for i in 0..5 {
db.upsert_node("Person", &format!("g3:{}", i), UpsertNodeOptions::default())
.unwrap();
}
assert!(
db.segment_count().unwrap() >= 1,
"backpressure should trigger flush when immutable count >= max"
);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 15, "all 15 nodes should be visible");
db.close().unwrap();
}
#[test]
fn test_max_immutable_memtables_disabled_when_zero() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("max_imm_disabled");
let opts = DbOptions {
memtable_flush_threshold: 0, memtable_hard_cap_bytes: 0, max_immutable_memtables: 0, compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for batch in 0..5 {
for i in 0..3 {
db.upsert_node(
"Person",
&format!("b{}:{}", batch, i),
UpsertNodeOptions::default(),
)
.unwrap();
}
db.freeze_memtable().unwrap();
}
assert_eq!(
db.segment_count().unwrap(),
0,
"no flush should trigger with count backpressure disabled"
);
assert_eq!(db.immutable_memtable_count(), 5);
db.close().unwrap();
}
#[test]
fn test_close_drains_all_immutables() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("close_drain");
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
max_immutable_memtables: 0, compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
{
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for batch in 0..3 {
for i in 0..5 {
db.upsert_node(
"Person",
&format!("b{}:{}", batch, i),
UpsertNodeOptions::default(),
)
.unwrap();
}
db.freeze_memtable().unwrap();
}
for i in 0..5 {
db.upsert_node("Person", &format!("active:{}", i), UpsertNodeOptions::default())
.unwrap();
}
assert_eq!(db.immutable_memtable_count(), 3);
assert_eq!(db.segment_count().unwrap(), 0);
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 20, "all 20 nodes should survive close + reopen");
assert!(
db.segment_count().unwrap() >= 1,
"close() should have flushed to segments"
);
assert!(db.find_existing_node(1, "b0:0").unwrap().is_some());
assert!(db.find_existing_node(1, "b1:2").unwrap().is_some());
assert!(db.find_existing_node(1, "b2:4").unwrap().is_some());
assert!(db.find_existing_node(1, "active:3").unwrap().is_some());
db.close().unwrap();
}
}
#[test]
fn test_close_fast_preserves_wal_for_recovery() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("close_fast_wal");
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
max_immutable_memtables: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
{
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for batch in 0..3 {
for i in 0..5 {
db.upsert_node(
"Person",
&format!("b{}:{}", batch, i),
UpsertNodeOptions::default(),
)
.unwrap();
}
db.freeze_memtable().unwrap();
}
for i in 0..5 {
db.upsert_node("Person", &format!("active:{}", i), UpsertNodeOptions::default())
.unwrap();
}
assert_eq!(db.immutable_memtable_count(), 3);
assert_eq!(db.segment_count().unwrap(), 0);
db.close_fast().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(
all.len(),
20,
"all 20 nodes should survive close_fast via WAL replay"
);
assert_eq!(
db.segment_count().unwrap(),
0,
"close_fast should not create segments"
);
db.close().unwrap();
}
}
#[test]
fn test_compaction_respects_flush_published_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("compact_flush_interleave");
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
max_immutable_memtables: 0,
compact_after_n_flushes: 0, wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..10 {
db.upsert_node("Person", &format!("seg1:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
for i in 0..10 {
db.upsert_node("Person", &format!("seg2:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
assert_eq!(db.segment_count().unwrap(), 2);
db.compact().unwrap();
assert_eq!(db.segment_count().unwrap(), 1);
for i in 0..10 {
db.upsert_node("Person", &format!("seg3:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.flush().unwrap();
assert_eq!(db.segment_count().unwrap(), 2);
let all = db.get_nodes_by_labels("Person").unwrap();
assert_eq!(all.len(), 30);
db.compact().unwrap();
assert_eq!(db.segment_count().unwrap(), 1);
assert_eq!(db.get_nodes_by_labels("Person").unwrap().len(), 30);
db.close().unwrap();
}
#[test]
fn test_close_with_active_and_immutable_data() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("close_mixed");
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
max_immutable_memtables: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let mut node_ids = Vec::new();
{
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let id = db
.upsert_node("Person", "frozen_a", UpsertNodeOptions::default())
.unwrap();
node_ids.push(id);
db.freeze_memtable().unwrap();
let id = db
.upsert_node("Person", "active_b", UpsertNodeOptions::default())
.unwrap();
node_ids.push(id);
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for &id in &node_ids {
assert!(db.get_node(id).unwrap().is_some());
}
assert_eq!(db.get_nodes_by_labels("Person").unwrap().len(), 2);
assert!(db.segment_count().unwrap() >= 1, "close() should have flushed");
db.close().unwrap();
}
}
#[test]
fn test_close_empty_db_is_noop() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("close_empty");
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
assert_eq!(db.segment_count().unwrap(), 0);
db.close().unwrap();
let db2 = DatabaseEngine::open(&db_path, &opts).unwrap();
assert_eq!(db2.segment_count().unwrap(), 0);
db2.close().unwrap();
}
#[test]
fn test_backpressure_bytes_and_count_combined() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("bp_combined");
let opts = DbOptions {
memtable_flush_threshold: 0, memtable_hard_cap_bytes: 4096, max_immutable_memtables: 1, compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..3 {
db.upsert_node("Person", &format!("x:{}", i), UpsertNodeOptions::default())
.unwrap();
}
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_memtable_count(), 1);
db.upsert_node("Person", "trigger", UpsertNodeOptions::default())
.unwrap();
assert!(
db.segment_count().unwrap() >= 1,
"count-based backpressure should trigger flush"
);
db.close().unwrap();
}
#[test]
fn test_data_visible_while_in_flight() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id = db
.upsert_node("Person", "visible", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 1);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
assert_eq!(db.in_flight_count(), 1);
assert_eq!(db.immutable_epoch_count(), 1);
assert!(db.get_node(id).unwrap().is_some());
assert!(db.get_node_by_key("Person", "visible").unwrap().is_some());
release_tx.send(()).unwrap();
let seg = db.wait_one_flush().unwrap();
assert!(seg.is_some());
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 1);
assert!(db.get_node(id).unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_multiple_epochs_all_visible_during_flush() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id1 = db
.upsert_node(
"Person",
"gen1_key",
UpsertNodeOptions {
weight: 1.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let id2 = db
.upsert_node(
"Person",
"gen2_key",
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let id3 = db
.upsert_node(
"Person",
"gen3_key",
UpsertNodeOptions {
weight: 3.0,
..Default::default()
},
)
.unwrap();
assert_eq!(db.immutable_epoch_count(), 2);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap(); db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
assert!(db.get_node(id1).unwrap().is_some());
assert!(db.get_node(id2).unwrap().is_some());
assert!(db.get_node(id3).unwrap().is_some());
assert!(db.get_node_by_key("Person", "gen1_key").unwrap().is_some());
assert!(db.get_node_by_key("Person", "gen2_key").unwrap().is_some());
assert!(db.get_node_by_key("Person", "gen3_key").unwrap().is_some());
release_tx.send(()).unwrap();
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 3);
db.close().unwrap();
}
#[test]
fn test_auto_flush_is_async_not_blocking() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 256,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let (ready_rx, release_tx) = db.set_flush_pause();
let mut ids = Vec::new();
for i in 0..5 {
let id = db
.upsert_node("Person", &format!("af_{}", i), UpsertNodeOptions::default())
.unwrap();
ids.push(id);
}
ready_rx.recv().unwrap();
for &id in &ids {
assert!(
db.get_node(id).unwrap().is_some(),
"node {} not visible during in-flight flush",
id
);
}
release_tx.send(()).unwrap();
db.flush().unwrap();
assert!(db.segment_count().unwrap() >= 1);
db.close().unwrap();
}
#[test]
fn test_apply_removes_epoch_after_publish() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id = db
.upsert_node("Person", "apply_test", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 1);
db.enqueue_one_flush().unwrap();
let seg = db.wait_one_flush().unwrap();
assert!(seg.is_some());
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 1);
assert!(db.get_node(id).unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_worker_failure_keeps_epoch_visible() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id = db
.upsert_node("Person", "fail_test", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.set_flush_force_error();
db.enqueue_one_flush().unwrap();
let result = db.wait_one_flush();
assert!(result.is_err());
assert_eq!(db.immutable_epoch_count(), 1);
assert_eq!(db.in_flight_count(), 0);
assert!(db.get_node(id).unwrap().is_some());
assert!(db.get_node_by_key("Person", "fail_test").unwrap().is_some());
db.close().unwrap();
}
#[test]
fn test_backpressure_counts_all_epochs_including_in_flight() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
max_immutable_memtables: 2,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
db.upsert_node("Person", "bp1", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
db.upsert_node("Person", "bp2", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
assert_eq!(db.immutable_epoch_count(), 2);
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
assert_eq!(db.immutable_epoch_count(), 2);
release_tx.send(()).unwrap();
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert!(db.segment_count().unwrap() >= 2);
db.close().unwrap();
}
#[test]
fn test_flush_sync_barrier_drains_all_epochs() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..3 {
db.upsert_node("Person", &format!("sync_{}", i), UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
}
assert_eq!(db.immutable_epoch_count(), 3);
db.flush().unwrap();
assert_eq!(db.immutable_epoch_count(), 0);
assert_eq!(db.segment_count().unwrap(), 3);
for i in 0..3 {
assert!(db
.get_node_by_key("Person", &format!("sync_{}", i))
.unwrap()
.is_some());
}
db.close().unwrap();
}
#[test]
#[ignore] fn test_async_flush_latency_profile() {
use std::time::Instant;
const WRITE_COUNT: u64 = 10_000;
const THRESHOLD: usize = 1024 * 1024; const SYNC_FLUSH_INTERVAL: u64 = 3300;
fn percentile(sorted: &[u128], p: f64) -> u128 {
let idx = ((sorted.len() as f64) * p / 100.0) as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn write_opts(i: u64) -> UpsertNodeOptions {
let mut props = BTreeMap::new();
props.insert(
"name".to_string(),
PropValue::String(format!("bench_node_{}", i)),
);
props.insert(
"category".to_string(),
PropValue::String("latency_test".to_string()),
);
props.insert("score".to_string(), PropValue::Float(i as f64 * 0.001));
UpsertNodeOptions {
props,
..Default::default()
}
}
let sync_latencies = {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
create_if_missing: true,
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut latencies = Vec::with_capacity(WRITE_COUNT as usize);
for i in 0..WRITE_COUNT {
let start = Instant::now();
db.upsert_node("Person", &format!("n{}", i), write_opts(i))
.unwrap();
if (i + 1) % SYNC_FLUSH_INTERVAL == 0 {
db.flush().unwrap();
}
latencies.push(start.elapsed().as_micros());
}
db.close().unwrap();
latencies.sort_unstable();
latencies
};
let async_latencies = {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
create_if_missing: true,
memtable_flush_threshold: THRESHOLD,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut latencies = Vec::with_capacity(WRITE_COUNT as usize);
for i in 0..WRITE_COUNT {
let start = Instant::now();
db.upsert_node("Person", &format!("n{}", i), write_opts(i))
.unwrap();
latencies.push(start.elapsed().as_micros());
}
db.close().unwrap();
latencies.sort_unstable();
latencies
};
let sync_blocked = sync_latencies.iter().filter(|&&l| l > 1000).count();
let async_blocked = async_latencies.iter().filter(|&&l| l > 1000).count();
eprintln!(
"\n=== Async Flush Latency Profile ({} writes, threshold=1MB) ===\n",
WRITE_COUNT
);
eprintln!("sync_baseline (flush every {}):", SYNC_FLUSH_INTERVAL);
eprintln!(
" p50={:>6}µs p95={:>6}µs p99={:>6}µs max={:>6}µs blocked(>1ms)={}",
percentile(&sync_latencies, 50.0),
percentile(&sync_latencies, 95.0),
percentile(&sync_latencies, 99.0),
sync_latencies.last().unwrap(),
sync_blocked,
);
eprintln!("async_auto_flush (threshold=1MB):");
eprintln!(
" p50={:>6}µs p95={:>6}µs p99={:>6}µs max={:>6}µs blocked(>1ms)={}",
percentile(&async_latencies, 50.0),
percentile(&async_latencies, 95.0),
percentile(&async_latencies, 99.0),
async_latencies.last().unwrap(),
async_blocked,
);
let sync_p99 = percentile(&sync_latencies, 99.0);
let async_p99 = percentile(&async_latencies, 99.0);
eprintln!(
"\np99 improvement: sync={}µs → async={}µs ({:.0}% reduction)",
sync_p99,
async_p99,
if sync_p99 > 0 {
(1.0 - async_p99 as f64 / sync_p99 as f64) * 100.0
} else {
0.0
}
);
eprintln!(
"max improvement: sync={}µs → async={}µs ({:.0}% reduction)",
sync_latencies.last().unwrap(),
async_latencies.last().unwrap(),
if *sync_latencies.last().unwrap() > 0 {
(1.0 - *async_latencies.last().unwrap() as f64 / *sync_latencies.last().unwrap() as f64)
* 100.0
} else {
0.0
}
);
}
#[test]
fn test_property_index_manifest_reopens_and_reseeds_active_memtable() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let index_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
index_id = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap()
.index_id;
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let indexes = db.list_node_property_indexes().unwrap();
assert_eq!(indexes.len(), 1);
let info = wait_for_property_index_state(&db, index_id, SecondaryIndexState::Ready);
assert_eq!(info.index_id, index_id);
assert!(db
.active_memtable()
.secondary_index_declarations()
.contains_key(&index_id));
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
let node_id = db
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
let status_hash = hash_prop_equality_key(&PropValue::String("red".to_string()));
let active_memtable = db.active_memtable();
let eq_state = active_memtable.secondary_eq_state();
let eq_ids = eq_state
.get(&index_id)
.unwrap()
.get(&status_hash)
.unwrap();
assert!(eq_ids.contains(&node_id));
db.close().unwrap();
}
}
#[test]
fn test_ensure_property_index_while_flush_in_flight_preserves_manifest_and_seeding() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut props = BTreeMap::new();
props.insert(
"status".to_string(),
PropValue::String("active".to_string()),
);
let node_id = db
.upsert_node(
"Person",
"frozen",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
let info = db
.ensure_node_property_index("Person", "status", SecondaryIndexKind::Equality)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Building);
let status_hash = hash_prop_equality_key(&PropValue::String("active".to_string()));
let frozen_memtable = db.immutable_memtable(0);
let frozen_eq_state = frozen_memtable.secondary_eq_state();
let frozen_eq_ids = frozen_eq_state
.get(&info.index_id)
.unwrap()
.get(&status_hash)
.unwrap();
assert!(frozen_eq_ids.contains(&node_id));
assert_eq!(db.manifest().unwrap().secondary_indexes.len(), 1);
release_tx.send(()).unwrap();
assert!(db.wait_one_flush().unwrap().is_some());
let ready = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
let seg_dir = segment_dir(dir.path(), db.segments_for_test()[0].segment_id);
assert!(crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id).exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let ready = wait_for_property_index_state(&reopened, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
reopened.close().unwrap();
}
#[test]
fn test_ready_property_index_downgrades_when_flush_publish_missed_declaration_snapshot() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut props = BTreeMap::new();
props.insert(
"status".to_string(),
PropValue::String("active".to_string()),
);
let node_id = db
.upsert_node(
"Person",
"frozen",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let (publish_ready_rx, publish_release_tx) = db.set_flush_publish_pause();
db.enqueue_one_flush().unwrap();
publish_ready_rx.recv().unwrap();
let info = db
.ensure_node_property_index("Person", "status", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let (repair_ready_rx, repair_release_tx) = db.set_secondary_index_build_pause();
publish_release_tx.send(()).unwrap();
assert!(db.wait_one_flush().unwrap().is_some());
repair_ready_rx.recv().unwrap();
let building = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(building.state, SecondaryIndexState::Building);
let seg_dir = segment_dir(dir.path(), db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(!sidecar_path.exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
repair_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_published_property_query_route_stays_snapshot_stable_across_build_completion() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert(
"status".to_string(),
PropValue::String("active".to_string()),
);
let node_id = db
.upsert_node(
"Person",
"snapshot-stable",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
let (build_ready_rx, build_release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person", "status", SecondaryIndexKind::Equality)
.unwrap();
build_ready_rx.recv().unwrap();
let pinned = db.published_state();
let pinned_before = pinned
.view
.find_nodes_outcome(1, "status", &PropValue::String("active".to_string()))
.unwrap();
assert_eq!(pinned_before.value, vec![node_id]);
assert_eq!(
pinned_before.route,
PropertyQueryRouteKind::EqualityScanFallback
);
assert!(pinned_before.followup.is_none());
build_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let pinned_after = pinned
.view
.find_nodes_outcome(1, "status", &PropValue::String("active".to_string()))
.unwrap();
assert_eq!(pinned_after.value, vec![node_id]);
assert_eq!(
pinned_after.route,
PropertyQueryRouteKind::EqualityScanFallback
);
assert!(pinned_after.followup.is_none());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_ready_property_index_downgrades_when_bg_compaction_missed_declaration_snapshot() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 1,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let active = PropValue::String("active".to_string());
let (compact_ready_rx, compact_release_tx) = db.set_bg_compact_pause();
let mut first_props = BTreeMap::new();
first_props.insert("status".to_string(), active.clone());
let node_a = db
.upsert_node(
"Person",
"seg_a",
UpsertNodeOptions {
props: first_props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let mut second_props = BTreeMap::new();
second_props.insert("status".to_string(), active.clone());
let node_b = db
.upsert_node(
"Person",
"seg_b",
UpsertNodeOptions {
props: second_props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
compact_ready_rx.recv().unwrap();
assert_eq!(db.segment_count().unwrap(), 2);
let expected_ids = vec![node_a, node_b];
let info = db
.ensure_node_property_index("Person", "status", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let (repair_ready_rx, repair_release_tx) = db.set_secondary_index_build_pause();
compact_release_tx.send(()).unwrap();
assert!(db.wait_for_bg_compaction().is_some());
repair_ready_rx.recv().unwrap();
let building = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(building.state, SecondaryIndexState::Building);
assert_eq!(db.segment_count().unwrap(), 1);
let seg_dir = segment_dir(dir.path(), db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(!sidecar_path.exists());
db.reset_property_query_routes();
let mut results = db
.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap();
results.sort_unstable();
assert_eq!(results, expected_ids);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
repair_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap(),
expected_ids
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_failed_property_indexes_survive_reopen_and_queries_fallback() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut color_props = BTreeMap::new();
color_props.insert("color".to_string(), PropValue::String("red".to_string()));
let color_id = db
.upsert_node(
"Person",
"color",
UpsertNodeOptions {
props: color_props,
..Default::default()
},
)
.unwrap();
let mut score_props = BTreeMap::new();
score_props.insert("score".to_string(), PropValue::Int(10));
let score_id = db
.upsert_node(
"Person",
"score",
UpsertNodeOptions {
props: score_props,
..Default::default()
},
)
.unwrap();
let eq = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let range = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
db.with_runtime_manifest_write(|manifest| {
for entry in &mut manifest.secondary_indexes {
if entry.index_id == eq.index_id {
entry.state = SecondaryIndexState::Failed;
entry.last_error = Some("eq failed".to_string());
} else if entry.index_id == range.index_id {
entry.state = SecondaryIndexState::Failed;
entry.last_error = Some("range failed".to_string());
}
}
Ok(())
})
.unwrap();
db.rebuild_secondary_index_catalog().unwrap();
assert_eq!(
db.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap(),
vec![color_id]
);
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![score_id]
);
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let indexes = db.list_node_property_indexes().unwrap();
assert_eq!(indexes.len(), 2);
assert!(
indexes
.iter()
.all(|info| info.state == SecondaryIndexState::Failed),
"{indexes:?}"
);
assert!(indexes
.iter()
.any(|info| info.last_error.as_deref() == Some("eq failed")));
assert!(indexes
.iter()
.any(|info| info.last_error.as_deref() == Some("range failed")));
db.reset_property_query_routes();
assert_eq!(
db.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap()
.len(),
1
);
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap()
.len(),
1
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.range_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
assert_eq!(routes.range_index_lookup, 0);
db.close().unwrap();
}
}
#[test]
fn test_zero_declaration_flush_and_compaction_skip_equality_artifacts() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for key in ["a", "b"] {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
db.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
let first_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
assert_no_legacy_property_components(&first_seg_dir);
assert!(!first_seg_dir
.join(crate::segment_writer::SECONDARY_INDEX_DIRNAME)
.exists());
for key in ["c", "d"] {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("blue".to_string()));
db.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
assert_no_legacy_property_components(&compacted_seg_dir);
assert!(!compacted_seg_dir
.join(crate::segment_writer::SECONDARY_INDEX_DIRNAME)
.exists());
db.close().unwrap();
}
#[test]
fn test_equality_index_backfills_existing_segments_and_compaction_preserves_sidecars() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let first_id = db
.upsert_node(
"Person",
"first",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let first_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let first_sidecar =
crate::segment_writer::node_prop_eq_sidecar_path(&first_seg_dir, info.index_id);
assert!(first_sidecar.exists());
let second_id = db
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
for segment in &db.segments_for_test() {
let seg_dir = segment_dir(&db_path, segment.segment_id);
let sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(sidecar_path.exists());
}
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let compacted_sidecar =
crate::segment_writer::node_prop_eq_sidecar_path(&compacted_seg_dir, info.index_id);
assert!(compacted_sidecar.exists());
assert_no_legacy_property_components(&compacted_seg_dir);
db.reset_property_query_routes();
let mut ids = db.find_nodes("Person", "color", &red).unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![first_id, second_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_missing_equality_sidecar_reopens_and_repairs_to_ready() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let index_id;
let seg_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
db.upsert_node(
"Person",
"repair-me",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
seg_id = db.segments_for_test()[0].segment_id;
db.close().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, index_id);
std::fs::remove_file(&sidecar_path).unwrap();
assert!(!sidecar_path.exists());
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
wait_for_property_index_state(&reopened, index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, index_id);
assert!(repaired_sidecar_path.exists());
assert_eq!(
reopened
.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap()
.len(),
1
);
reopened.close().unwrap();
}
#[test]
fn test_corrupt_equality_sidecar_reopens_failed_and_queries_fallback() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let index_id;
let seg_id;
let red = PropValue::String("red".to_string());
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"broken",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
seg_id = db.segments_for_test()[0].segment_id;
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
db.close().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, index_id);
std::fs::write(&sidecar_path, [1u8, 2, 3]).unwrap();
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = reopened
.list_node_property_indexes().unwrap()
.into_iter()
.find(|info| info.index_id == index_id)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Failed);
assert!(info.last_error.is_some());
reopened.reset_property_query_routes();
assert_eq!(reopened.find_nodes("Person", "color", &red).unwrap().len(), 1);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
reopened.close().unwrap();
}
#[test]
fn test_ready_equality_sidecar_tail_corruption_does_not_full_scan_on_open() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let blue = PropValue::String("blue".to_string());
let index_id;
let seg_id;
let red_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
red_id = db
.upsert_node(
"Person",
"red",
UpsertNodeOptions {
props: red_props,
..Default::default()
},
)
.unwrap();
for key in ["blue-a", "blue-b"] {
let mut blue_props = BTreeMap::new();
blue_props.insert("color".to_string(), blue.clone());
db.upsert_node(
"Person",
key,
UpsertNodeOptions {
props: blue_props,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
seg_id = db.segments_for_test()[0].segment_id;
db.close().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, index_id);
corrupt_equality_sidecar_tail_group_order_in_place(&sidecar_path, hash_prop_equality_key(&blue));
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = reopened
.list_node_property_indexes()
.unwrap()
.into_iter()
.find(|info| info.index_id == index_id)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Ready);
assert!(info.last_error.is_none());
reopened.reset_property_query_routes();
assert_eq!(reopened.find_nodes("Person", "color", &red).unwrap(), vec![red_id]);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
reopened.close().unwrap();
}
#[test]
fn test_missing_equality_sidecar_while_open_queries_fallback_and_repairs() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let blue = PropValue::String("blue".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"repair-live",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
std::fs::remove_file(&sidecar_path).unwrap();
assert!(!sidecar_path.exists());
db.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let mut unrelated_props = BTreeMap::new();
unrelated_props.insert("color".to_string(), blue.clone());
let unrelated_id = db
.upsert_node(
"Person",
"live-counter-node",
UpsertNodeOptions {
props: unrelated_props,
..Default::default()
},
)
.unwrap();
db.upsert_edge(
node_id,
unrelated_id,
"FRIENDS_WITH",
UpsertEdgeOptions {
..Default::default()
},
)
.unwrap();
let expected_after_degrade = (
db.next_node_id().unwrap(),
db.next_edge_id().unwrap(),
db.engine_seq_for_test(),
);
let (repair_ready_rx, repair_release_tx) = db.set_secondary_index_build_pause();
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
repair_ready_rx.recv().unwrap();
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
let manifest_after_degrade = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert_eq!(
manifest_after_degrade.next_node_id,
expected_after_degrade.0
);
assert_eq!(
manifest_after_degrade.next_edge_id,
expected_after_degrade.1
);
assert_eq!(
manifest_after_degrade.next_engine_seq,
expected_after_degrade.2
);
let mut later_props = BTreeMap::new();
later_props.insert("color".to_string(), blue);
let later_id = db
.upsert_node(
"Person",
"repair-counter-node",
UpsertNodeOptions {
props: later_props,
..Default::default()
},
)
.unwrap();
db.upsert_edge(
node_id,
later_id,
"COLLABORATES_WITH",
UpsertEdgeOptions {
..Default::default()
},
)
.unwrap();
let expected_after_repair = (
db.next_node_id().unwrap(),
db.next_edge_id().unwrap(),
db.engine_seq_for_test(),
);
repair_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
let manifest_after_repair = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert_eq!(manifest_after_repair.next_node_id, expected_after_repair.0);
assert_eq!(manifest_after_repair.next_edge_id, expected_after_repair.1);
assert_eq!(
manifest_after_repair.next_engine_seq,
expected_after_repair.2
);
db.close().unwrap();
}
#[test]
fn test_corrupt_equality_sidecar_while_open_queries_fallback_and_marks_failed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"fail-live",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
corrupt_sidecar_header_in_place(&sidecar_path);
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
let failed = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Failed);
assert_eq!(failed.state, SecondaryIndexState::Failed);
assert!(failed.last_error.is_some());
db.close().unwrap();
}
#[test]
fn test_compaction_with_corrupt_ready_sidecar_succeeds_and_marks_failed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let first_id = db
.upsert_node(
"Person",
"first",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let second_id = db
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
corrupt_sidecar_header_in_place(&sidecar_path);
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let failed = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Failed);
assert_eq!(failed.state, SecondaryIndexState::Failed);
assert!(failed.last_error.is_some());
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let compacted_sidecar =
crate::segment_writer::node_prop_eq_sidecar_path(&compacted_seg_dir, info.index_id);
assert!(compacted_sidecar.exists());
db.reset_property_query_routes();
let mut ids = db.find_nodes("Person", "color", &red).unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![first_id, second_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
db.close().unwrap();
}
#[test]
fn test_compaction_with_missing_ready_sidecar_rebuilds_equality_index_via_targeted_decode() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let first_id = db
.upsert_node(
"Person",
"first-missing-sidecar",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let second_id = db
.upsert_node(
"Person",
"second-missing-sidecar",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let older_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let missing_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&older_seg_dir, info.index_id);
std::fs::remove_file(&missing_sidecar_path).unwrap();
assert!(!missing_sidecar_path.exists());
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let rebuilt = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(rebuilt.state, SecondaryIndexState::Ready);
assert!(rebuilt.last_error.is_none());
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let compacted_sidecar =
crate::segment_writer::node_prop_eq_sidecar_path(&compacted_seg_dir, info.index_id);
assert!(compacted_sidecar.exists());
db.reset_property_query_routes();
let mut ids = db.find_nodes("Person", "color", &red).unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![first_id, second_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let reopened_info = reopened
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(reopened_info.state, SecondaryIndexState::Ready);
assert!(reopened_info.last_error.is_none());
reopened.reset_property_query_routes();
let mut reopened_ids = reopened.find_nodes("Person", "color", &red).unwrap();
reopened_ids.sort_unstable();
assert_eq!(reopened_ids, vec![first_id, second_id]);
let reopened_routes = reopened.property_query_route_snapshot();
assert_eq!(reopened_routes.equality_scan_fallback, 0);
assert_eq!(reopened_routes.equality_index_lookup, 1);
reopened.close().unwrap();
}
#[test]
fn test_drop_equality_index_routes_to_fallback_and_cleans_sidecar() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"drop-equality",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let indexed_routes = db.property_query_route_snapshot();
assert_eq!(indexed_routes.equality_scan_fallback, 0);
assert_eq!(indexed_routes.equality_index_lookup, 1);
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(sidecar_path.exists());
assert!(db
.drop_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap());
assert!(
db.list_node_property_indexes().unwrap()
.into_iter()
.all(|entry| entry.index_id != info.index_id)
);
assert!(
!db.active_memtable()
.secondary_eq_state()
.contains_key(&info.index_id)
);
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let fallback_routes = db.property_query_route_snapshot();
assert_eq!(fallback_routes.equality_scan_fallback, 1);
assert_eq!(fallback_routes.equality_index_lookup, 0);
wait_for_path_absent(&sidecar_path);
let manifest = read_component_manifest_for_test(&seg_dir);
assert!(manifest.components.iter().all(|record| {
!matches!(
&record.kind,
crate::segment_components::SegmentComponentKind::NodePropertyEqualityIndex {
index_id
} if *index_id == info.index_id
)
}));
db.close().unwrap();
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(reopened.list_node_property_indexes().unwrap().is_empty());
reopened.reset_property_query_routes();
assert_eq!(reopened.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let reopened_routes = reopened.property_query_route_snapshot();
assert_eq!(reopened_routes.equality_scan_fallback, 1);
assert_eq!(reopened_routes.equality_index_lookup, 0);
reopened.close().unwrap();
}
#[test]
fn test_equality_backfill_survives_compaction_during_build() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for key in ["first", "second"] {
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
db.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
}
let (ready_rx, release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap().len(), 2);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_equality_index_close_while_build_paused_reopens_and_resumes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"close-paused-eq",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let (ready_rx, release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
let close_handle = std::thread::spawn(move || {
let result = db.close();
let _ = done_tx.send(());
result
});
let _ = done_rx.recv_timeout(std::time::Duration::from_millis(100));
release_tx.send(()).unwrap();
done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
close_handle.join().unwrap().unwrap();
let manifest_after_close = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
let entry_after_close = manifest_after_close
.secondary_indexes
.iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(entry_after_close.state, SecondaryIndexState::Building);
assert!(entry_after_close.last_error.is_none());
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(!sidecar_path.exists());
let reopened = DatabaseEngine::open(&db_path, &opts).unwrap();
wait_for_property_index_state(&reopened, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
reopened.reset_property_query_routes();
assert_eq!(reopened.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
reopened.close().unwrap();
}
#[test]
fn test_drop_equality_index_while_build_paused_stale_sidecar_does_not_resurrect() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"drop-paused-eq",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let (ready_rx, release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
assert!(db
.drop_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap());
assert!(db.list_node_property_indexes().unwrap().is_empty());
let manifest_after_drop = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert!(manifest_after_drop.secondary_indexes.is_empty());
release_tx.send(()).unwrap();
db.close().unwrap();
let stale_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
let mut stale_groups = BTreeMap::new();
stale_groups.insert(hash_prop_equality_key(&red), vec![node_id]);
std::fs::create_dir_all(stale_sidecar_path.parent().unwrap()).unwrap();
crate::segment_writer::write_node_prop_eq_sidecar_to_path(&stale_sidecar_path, &stale_groups)
.unwrap();
assert!(stale_sidecar_path.exists());
let reopened = DatabaseEngine::open(&db_path, &opts).unwrap();
assert!(reopened.list_node_property_indexes().unwrap().is_empty());
reopened.reset_property_query_routes();
assert_eq!(reopened.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
reopened.close().unwrap();
}
#[test]
fn test_property_range_index_manifest_reopens_and_reseeds_active_memtable() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let node_id = db
.upsert_node(
"Person",
"frozen-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let (ready_rx, release_tx) = db.set_flush_pause();
db.enqueue_one_flush().unwrap();
ready_rx.recv().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Building);
let frozen_memtable = db.immutable_memtable(0);
let frozen_range_state = frozen_memtable.secondary_range_state();
let frozen_range = frozen_range_state
.get(&info.index_id)
.unwrap();
let score_10 = numeric_range_sort_key_for_value(&PropValue::Int(10)).unwrap();
assert!(frozen_range.contains(&(score_10, node_id)));
release_tx.send(()).unwrap();
assert!(db.wait_one_flush().unwrap().is_some());
let ready = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
let seg_dir = segment_dir(dir.path(), db.segments_for_test()[0].segment_id);
assert!(crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id).exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
db.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let ready = wait_for_property_index_state(&reopened, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
reopened.close().unwrap();
}
#[test]
fn test_ready_property_range_index_downgrades_when_flush_publish_missed_declaration_snapshot() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let node_id = db
.upsert_node(
"Person",
"frozen-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.freeze_memtable().unwrap();
let (publish_ready_rx, publish_release_tx) = db.set_flush_publish_pause();
db.enqueue_one_flush().unwrap();
publish_ready_rx.recv().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let (repair_ready_rx, repair_release_tx) = db.set_secondary_index_build_pause();
publish_release_tx.send(()).unwrap();
assert!(db.wait_one_flush().unwrap().is_some());
repair_ready_rx.recv().unwrap();
let building = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(building.state, SecondaryIndexState::Building);
let seg_dir = segment_dir(dir.path(), db.segments_for_test()[0].segment_id);
let sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
assert!(!sidecar_path.exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 1);
assert_eq!(routes.range_index_lookup, 0);
repair_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_missing_range_sidecar_reopens_and_repairs_to_ready() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let index_id;
let seg_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
db.upsert_node(
"Person",
"repair-me-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
seg_id = db.segments_for_test()[0].segment_id;
db.close().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
let sidecar_path = crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, index_id);
std::fs::remove_file(&sidecar_path).unwrap();
assert!(!sidecar_path.exists());
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
wait_for_property_index_state(&reopened, index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, index_id);
assert!(repaired_sidecar_path.exists());
assert_eq!(
reopened
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap()
.len(),
1
);
reopened.close().unwrap();
}
#[test]
fn test_corrupt_range_sidecar_reopens_failed_and_queries_fallback() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let index_id;
let seg_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let node_id = db
.upsert_node(
"Person",
"broken-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
seg_id = db.segments_for_test()[0].segment_id;
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
db.close().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
let sidecar_path = crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, index_id);
std::fs::write(&sidecar_path, [1u8, 2, 3]).unwrap();
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = reopened
.list_node_property_indexes().unwrap()
.into_iter()
.find(|info| info.index_id == index_id)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Failed);
assert!(info.last_error.is_some());
reopened.reset_property_query_routes();
assert_eq!(
reopened
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap()
.len(),
1
);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 1);
assert_eq!(routes.range_index_lookup, 0);
reopened.close().unwrap();
}
#[test]
fn test_ready_range_sidecar_tail_corruption_does_not_full_scan_on_open() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let index_id;
let seg_id;
let first_id;
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut first_id_for_setup = None;
for (key, score) in [("score-10", 10), ("score-20", 20), ("score-30", 30)] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(score));
let id = db
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
if score == 10 {
first_id_for_setup = Some(id);
}
}
first_id = first_id_for_setup.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
seg_id = db.segments_for_test()[0].segment_id;
db.close().unwrap();
}
let seg_dir = segment_dir(&db_path, seg_id);
let sidecar_path = crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, index_id);
corrupt_range_sidecar_tail_sort_order_in_place(&sidecar_path);
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = reopened
.list_node_property_indexes()
.unwrap()
.into_iter()
.find(|info| info.index_id == index_id)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Ready);
assert!(info.last_error.is_none());
reopened.reset_property_query_routes();
assert_eq!(
reopened
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![first_id]
);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
reopened.close().unwrap();
}
#[test]
fn test_missing_range_sidecar_while_open_queries_fallback_and_repairs() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let node_id = db
.upsert_node(
"Person",
"repair-live-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let sidecar_path = crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
std::fs::remove_file(&sidecar_path).unwrap();
assert!(!sidecar_path.exists());
db.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let unrelated_id = db
.upsert_node(
"Person",
"live-counter-range",
UpsertNodeOptions {
..Default::default()
},
)
.unwrap();
db.upsert_edge(
node_id,
unrelated_id,
"FRIENDS_WITH",
UpsertEdgeOptions {
..Default::default()
},
)
.unwrap();
let expected_after_degrade = (
db.next_node_id().unwrap(),
db.next_edge_id().unwrap(),
db.engine_seq_for_test(),
);
let (repair_ready_rx, repair_release_tx) = db.set_secondary_index_build_pause();
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
repair_ready_rx.recv().unwrap();
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 1);
assert_eq!(routes.range_index_lookup, 0);
let manifest_after_degrade = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert_eq!(manifest_after_degrade.next_node_id, expected_after_degrade.0);
assert_eq!(manifest_after_degrade.next_edge_id, expected_after_degrade.1);
assert_eq!(manifest_after_degrade.next_engine_seq, expected_after_degrade.2);
let later_id = db
.upsert_node(
"Person",
"repair-counter-range",
UpsertNodeOptions {
..Default::default()
},
)
.unwrap();
db.upsert_edge(
node_id,
later_id,
"COLLABORATES_WITH",
UpsertEdgeOptions {
..Default::default()
},
)
.unwrap();
let expected_after_repair = (
db.next_node_id().unwrap(),
db.next_edge_id().unwrap(),
db.engine_seq_for_test(),
);
repair_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
let manifest_after_repair = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert_eq!(manifest_after_repair.next_node_id, expected_after_repair.0);
assert_eq!(manifest_after_repair.next_edge_id, expected_after_repair.1);
assert_eq!(manifest_after_repair.next_engine_seq, expected_after_repair.2);
db.close().unwrap();
}
#[test]
fn test_corrupt_range_sidecar_while_open_queries_fallback_and_marks_failed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let node_id = db
.upsert_node(
"Person",
"corrupt-live-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
corrupt_sidecar_header_in_place(&sidecar_path);
let unrelated_id = db
.upsert_node(
"Person",
"failed-counter-range",
UpsertNodeOptions {
..Default::default()
},
)
.unwrap();
db.upsert_edge(
node_id,
unrelated_id,
"RELATED_TO",
UpsertEdgeOptions {
..Default::default()
},
)
.unwrap();
let expected_after_degrade = (
db.next_node_id().unwrap(),
db.next_edge_id().unwrap(),
db.engine_seq_for_test(),
);
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 1);
assert_eq!(routes.range_index_lookup, 0);
let failed = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Failed);
assert_eq!(failed.state, SecondaryIndexState::Failed);
assert!(failed.last_error.is_some());
let manifest_after_degrade = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
assert_eq!(manifest_after_degrade.next_node_id, expected_after_degrade.0);
assert_eq!(manifest_after_degrade.next_edge_id, expected_after_degrade.1);
assert_eq!(manifest_after_degrade.next_engine_seq, expected_after_degrade.2);
db.close().unwrap();
}
#[test]
fn test_compaction_with_corrupt_ready_range_sidecar_succeeds_and_marks_failed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let first_id = db
.upsert_node(
"Person",
"first-range",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let second_id = db
.upsert_node(
"Person",
"second-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let sidecar_path = crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
corrupt_sidecar_header_in_place(&sidecar_path);
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let failed = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(failed.state, SecondaryIndexState::Failed);
assert!(failed.last_error.is_some());
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let compacted_sidecar =
crate::segment_writer::node_prop_range_sidecar_path(&compacted_seg_dir, info.index_id);
assert!(compacted_sidecar.exists());
db.reset_property_query_routes();
let mut ids = db
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![first_id, second_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 1);
assert_eq!(routes.range_index_lookup, 0);
db.close().unwrap();
}
#[test]
fn test_compaction_with_missing_ready_range_sidecar_rebuilds_index_via_targeted_decode() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let first_id = db
.upsert_node(
"Person",
"first-missing-range-sidecar",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let second_id = db
.upsert_node(
"Person",
"second-missing-range-sidecar",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let older_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let missing_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&older_seg_dir, info.index_id);
std::fs::remove_file(&missing_sidecar_path).unwrap();
assert!(!missing_sidecar_path.exists());
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let rebuilt = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(rebuilt.state, SecondaryIndexState::Ready);
assert!(rebuilt.last_error.is_none());
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let compacted_sidecar =
crate::segment_writer::node_prop_range_sidecar_path(&compacted_seg_dir, info.index_id);
assert!(compacted_sidecar.exists());
db.reset_property_query_routes();
let mut ids = db
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![first_id, second_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
db.close().unwrap();
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let reopened_info = reopened
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(reopened_info.state, SecondaryIndexState::Ready);
assert!(reopened_info.last_error.is_none());
reopened.reset_property_query_routes();
let mut reopened_ids = reopened
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap();
reopened_ids.sort_unstable();
assert_eq!(reopened_ids, vec![first_id, second_id]);
let reopened_routes = reopened.property_query_route_snapshot();
assert_eq!(reopened_routes.range_scan_fallback, 0);
assert_eq!(reopened_routes.range_index_lookup, 1);
reopened.close().unwrap();
}
#[test]
fn test_drop_range_index_routes_to_fallback_cleans_sidecar_and_stays_dropped() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let first_id = db
.upsert_node(
"Person",
"drop-range-first",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![first_id]
);
let indexed_routes = db.property_query_route_snapshot();
assert_eq!(indexed_routes.range_scan_fallback, 0);
assert_eq!(indexed_routes.range_index_lookup, 1);
let first_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let first_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&first_seg_dir, info.index_id);
assert!(first_sidecar_path.exists());
let packed_core_before_drop = packed_core_snapshot_for_test(&first_seg_dir);
assert!(db
.drop_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap());
assert!(
db.list_node_property_indexes().unwrap()
.into_iter()
.all(|entry| entry.index_id != info.index_id)
);
assert!(
!db.active_memtable()
.secondary_range_state()
.contains_key(&info.index_id)
);
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![first_id]
);
let fallback_routes = db.property_query_route_snapshot();
assert_eq!(fallback_routes.range_scan_fallback, 1);
assert_eq!(fallback_routes.range_index_lookup, 0);
wait_for_path_absent(&first_sidecar_path);
assert_packed_core_unchanged_for_test(
&first_seg_dir,
&packed_core_before_drop,
"declared range index drop cleanup",
);
let first_manifest = read_component_manifest_for_test(&first_seg_dir);
assert!(first_manifest.components.iter().all(|record| {
!matches!(
&record.kind,
crate::segment_components::SegmentComponentKind::NodePropertyRangeIndex {
index_id
} if *index_id == info.index_id
)
}));
let second_id = db
.upsert_node(
"Person",
"drop-range-second",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let second_seg_dir = segment_dir(&db_path, db.segments_for_test()[1].segment_id);
let second_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&second_seg_dir, info.index_id);
assert!(!second_sidecar_path.exists());
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
let compacted_seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let compacted_sidecar =
crate::segment_writer::node_prop_range_sidecar_path(&compacted_seg_dir, info.index_id);
assert!(!compacted_sidecar.exists());
assert!(db.list_node_property_indexes().unwrap().is_empty());
db.reset_property_query_routes();
let mut ids = db
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![first_id, second_id]);
let post_compact_routes = db.property_query_route_snapshot();
assert_eq!(post_compact_routes.range_scan_fallback, 1);
assert_eq!(post_compact_routes.range_index_lookup, 0);
db.close().unwrap();
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(reopened.list_node_property_indexes().unwrap().is_empty());
reopened.reset_property_query_routes();
let mut reopened_ids = reopened
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap();
reopened_ids.sort_unstable();
assert_eq!(reopened_ids, vec![first_id, second_id]);
let reopened_routes = reopened.property_query_route_snapshot();
assert_eq!(reopened_routes.range_scan_fallback, 1);
assert_eq!(reopened_routes.range_index_lookup, 0);
reopened.close().unwrap();
}
#[test]
fn test_range_backfill_survives_compaction_during_build() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for key in ["first-range", "second-range"] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
db.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
}
let (ready_rx, release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let stats = db.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
db.reset_property_query_routes();
assert_eq!(
db.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap()
.len(),
2
);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_range_index_close_fast_while_build_paused_reopens_and_resumes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(10));
let node_id = db
.upsert_node(
"Person",
"close-fast-paused-range",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let seg_dir = segment_dir(&db_path, db.segments_for_test()[0].segment_id);
let (ready_rx, release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
let close_handle = std::thread::spawn(move || {
let result = db.close_fast();
let _ = done_tx.send(());
result
});
let _ = done_rx.recv_timeout(std::time::Duration::from_millis(100));
release_tx.send(()).unwrap();
done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
close_handle.join().unwrap().unwrap();
let manifest_after_close = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
let entry_after_close = manifest_after_close
.secondary_indexes
.iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(entry_after_close.state, SecondaryIndexState::Building);
assert!(entry_after_close.last_error.is_none());
let sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
assert!(!sidecar_path.exists());
let reopened = DatabaseEngine::open(&db_path, &opts).unwrap();
wait_for_property_index_state(&reopened, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_range_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
reopened.reset_property_query_routes();
assert_eq!(
reopened
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
Some(&PropertyRangeBound::Included(PropValue::Int(10))),
)
.unwrap(),
vec![node_id]
);
let routes = reopened.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
reopened.close().unwrap();
}
#[test]
fn test_open_rejects_conflicting_range_declarations_for_same_property() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
std::fs::create_dir_all(&db_path).unwrap();
let mut manifest = crate::manifest::default_manifest();
manifest.secondary_indexes = vec![
SecondaryIndexManifestEntry {
index_id: 1,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Range,
state: SecondaryIndexState::Building,
last_error: None,
},
SecondaryIndexManifestEntry {
index_id: 2,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Range,
state: SecondaryIndexState::Building,
last_error: None,
},
];
manifest.next_secondary_index_id = 3;
crate::manifest::write_manifest(&db_path, &manifest).unwrap();
match DatabaseEngine::open(&db_path, &DbOptions::default()) {
Err(EngineError::ManifestError(_)) => {}
Err(other) => panic!("expected ManifestError, got {}", other),
Ok(_) => panic!("expected conflicting range declarations to fail on open"),
}
}
#[test]
fn test_open_rejects_duplicate_secondary_index_ids_in_manifest() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
std::fs::create_dir_all(&db_path).unwrap();
let mut manifest = crate::manifest::default_manifest();
manifest.secondary_indexes = vec![
SecondaryIndexManifestEntry {
index_id: 1,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Building,
last_error: None,
},
SecondaryIndexManifestEntry {
index_id: 1,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "color".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Building,
last_error: None,
},
];
manifest.next_secondary_index_id = 2;
crate::manifest::write_manifest(&db_path, &manifest).unwrap();
match DatabaseEngine::open(&db_path, &DbOptions::default()) {
Err(EngineError::ManifestError(message)) => {
assert!(message.contains("duplicate secondary index id"));
}
Err(other) => panic!("expected ManifestError, got {}", other),
Ok(_) => panic!("expected duplicate secondary index ids to fail on open"),
}
}
#[test]
fn test_open_rejects_duplicate_equality_declarations_for_same_property() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
std::fs::create_dir_all(&db_path).unwrap();
let mut manifest = crate::manifest::default_manifest();
manifest.secondary_indexes = vec![
SecondaryIndexManifestEntry {
index_id: 1,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Building,
last_error: None,
},
SecondaryIndexManifestEntry {
index_id: 2,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Building,
last_error: None,
},
];
manifest.next_secondary_index_id = 3;
crate::manifest::write_manifest(&db_path, &manifest).unwrap();
match DatabaseEngine::open(&db_path, &DbOptions::default()) {
Err(EngineError::ManifestError(message)) => {
assert!(message.contains("duplicate secondary index declaration"));
}
Err(other) => panic!("expected ManifestError, got {}", other),
Ok(_) => panic!("expected duplicate equality declarations to fail on open"),
}
}
#[test]
fn test_shared_handle_clone_observes_state_and_close_is_family_wide() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let clone = db.clone();
let id = db
.upsert_node("Person", "shared", UpsertNodeOptions::default())
.unwrap();
assert_eq!(clone.get_node(id).unwrap().unwrap().key, "shared");
clone.close().unwrap();
assert!(matches!(db.get_node(id), Err(EngineError::DatabaseClosed)));
assert!(matches!(
db.upsert_node("Person", "after-close", UpsertNodeOptions::default()),
Err(EngineError::DatabaseClosed)
));
assert!(matches!(db.list_prune_policies(), Err(EngineError::DatabaseClosed)));
assert!(matches!(
db.list_node_property_indexes(),
Err(EngineError::DatabaseClosed)
));
assert!(matches!(db.stats(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.manifest(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.node_count(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.edge_count(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.next_node_id(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.next_edge_id(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.segment_count(), Err(EngineError::DatabaseClosed)));
assert!(matches!(
db.segment_tombstone_node_count(),
Err(EngineError::DatabaseClosed)
));
assert!(matches!(
db.segment_tombstone_edge_count(),
Err(EngineError::DatabaseClosed)
));
assert!(matches!(db.ingest_mode(), Err(EngineError::DatabaseClosed)));
assert!(matches!(db.close(), Err(EngineError::DatabaseClosed)));
let reopened = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert_eq!(reopened.get_node(id).unwrap().unwrap().key, "shared");
reopened.close().unwrap();
}
#[test]
fn test_shared_handle_reads_hold_old_published_snapshot_until_republish() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let reader = db.clone();
let writer = db.clone();
let (ready_rx, release_tx) = writer.set_runtime_publish_pause();
let (write_done_tx, write_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = writer.upsert_node("Person", "during-publish", UpsertNodeOptions::default());
let _ = write_done_tx.send(result);
});
ready_rx.recv().unwrap();
assert!(reader.get_node(1).unwrap().is_none());
assert!(reader.get_node_by_key("Person", "during-publish").unwrap().is_none());
assert!(reader.find_existing_node(1, "during-publish").unwrap().is_none());
let batch_nodes = reader.get_nodes(&[1]).unwrap();
assert!(batch_nodes[0].is_none());
let batch_keys = reader
.get_nodes_by_keys(&[NodeKeyQuery { label: "Person".to_string(), key: "during-publish".to_string() }])
.unwrap();
assert!(batch_keys[0].is_none());
release_tx.send(()).unwrap();
let id = write_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
assert_eq!(id, 1);
assert_eq!(reader.get_node(id).unwrap().unwrap().key, "during-publish");
assert_eq!(
reader.get_node_by_key("Person", "during-publish").unwrap().unwrap().id,
id
);
assert_eq!(
reader
.find_existing_node(1, "during-publish")
.unwrap()
.map(|(node_id, _)| node_id),
Some(id)
);
let batch_nodes = reader.get_nodes(&[id]).unwrap();
assert_eq!(batch_nodes[0].as_ref().unwrap().key, "during-publish");
let batch_keys = reader
.get_nodes_by_keys(&[NodeKeyQuery { label: "Person".to_string(), key: "during-publish".to_string() }])
.unwrap();
assert_eq!(batch_keys[0].as_ref().unwrap().id, id);
db.close().unwrap();
}
#[test]
fn test_shared_handle_snapshot_queries_keep_visible_label_enumeration_until_republish() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let reader = db.clone();
let writer = db.clone();
let id = db
.upsert_node("Person", "snapshot-label", UpsertNodeOptions::default())
.unwrap();
let initial_export = reader.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(initial_export.node_ids, vec![id]);
let initial_components = reader
.connected_components(&ComponentOptions::default())
.unwrap();
assert_eq!(initial_components.get(&id), Some(&id));
let (ready_rx, release_tx) = writer.set_runtime_publish_pause();
let (write_done_tx, write_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = writer.delete_node(id);
let _ = write_done_tx.send(result);
});
ready_rx.recv().unwrap();
let pinned_export = reader.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(
pinned_export.node_ids,
vec![id],
"old published snapshot must keep the pre-delete node label visible"
);
let pinned_components = reader
.connected_components(&ComponentOptions::default())
.unwrap();
assert_eq!(
pinned_components.get(&id),
Some(&id),
"connected_components must enumerate node labels from snapshot-visible membership"
);
release_tx.send(()).unwrap();
write_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
let after_export = reader.export_adjacency(&ExportOptions::default()).unwrap();
assert!(after_export.node_ids.is_empty());
let after_components = reader
.connected_components(&ComponentOptions::default())
.unwrap();
assert!(!after_components.contains_key(&id));
db.close().unwrap();
}
#[test]
fn test_transaction_pinned_snapshot_label_history() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "snapshot-label-history", UpsertNodeOptions::default())
.unwrap();
let pinned = db.published_read_view_for_test();
let pinned_person_id = pinned
.label_catalog
.resolve_node_label_for_read("Person")
.unwrap()
.unwrap();
assert_eq!(
pinned.get_node(id).unwrap().unwrap().label_ids.as_slice(),
&[pinned_person_id]
);
let mut txn = db.begin_write_txn().unwrap();
assert!(txn.add_node_label(TxnNodeRef::Id(id), "Employee").unwrap());
assert!(txn.remove_node_label(TxnNodeRef::Id(id), "Person").unwrap());
txn.commit().unwrap();
assert_eq!(
pinned.get_node(id).unwrap().unwrap().label_ids.as_slice(),
&[pinned_person_id],
"pinned reader must keep the old node labels after committed label changes"
);
assert_eq!(
pinned
.get_node_by_label_key(pinned_person_id, "snapshot-label-history")
.unwrap()
.map(|node| node.id),
Some(id)
);
assert!(pinned
.label_catalog
.resolve_node_label_for_read("Employee")
.unwrap()
.is_none());
let latest = db.get_node(id).unwrap().unwrap();
assert_eq!(latest.labels, vec!["Employee".to_string()]);
assert!(db
.get_node_by_key("Person", "snapshot-label-history")
.unwrap()
.is_none());
assert_eq!(
db.get_node_by_key("Employee", "snapshot-label-history")
.unwrap()
.map(|node| node.id),
Some(id)
);
db.close().unwrap();
}
#[test]
fn test_shared_handle_close_waits_for_admitted_read_and_rejects_later_reads() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let id = db
.upsert_node("Person", "close-read", UpsertNodeOptions::default())
.unwrap();
let reader = db.clone();
let closer = db.clone();
let (read_ready_rx, read_release_tx) = reader.set_runtime_read_pause();
let (read_done_tx, read_done_rx) = std::sync::mpsc::sync_channel(1);
let (close_done_tx, close_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = read_done_tx.send(reader.get_node(id));
});
read_ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
std::thread::spawn(move || {
let _ = close_done_tx.send(closer.close());
});
assert!(
close_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"close should wait for an already-admitted read"
);
assert!(matches!(db.get_node(id), Err(EngineError::DatabaseClosed)));
read_release_tx.send(()).unwrap();
let node = read_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap()
.unwrap();
assert_eq!(node.id, id);
close_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
let reopened = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert_eq!(reopened.get_node(id).unwrap().unwrap().key, "close-read");
reopened.close().unwrap();
}
#[test]
fn test_shared_handle_close_waits_for_admitted_write_to_finish() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let writer = db.clone();
let closer = db.clone();
let (ready_rx, release_tx) = writer.set_runtime_publish_pause();
let (write_done_tx, write_done_rx) = std::sync::mpsc::sync_channel(1);
let (close_done_tx, close_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = writer.upsert_node("Person", "close-barrier", UpsertNodeOptions::default());
let _ = write_done_tx.send(result);
});
ready_rx.recv().unwrap();
std::thread::spawn(move || {
let _ = close_done_tx.send(closer.close());
});
assert!(
close_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"close should wait for already-admitted work"
);
release_tx.send(()).unwrap();
let write_id = write_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
assert_eq!(write_id, 1);
close_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
assert!(matches!(db.get_node(write_id), Err(EngineError::DatabaseClosed)));
let reopened = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert_eq!(reopened.get_node(write_id).unwrap().unwrap().key, "close-barrier");
reopened.close().unwrap();
}
#[test]
fn test_shared_handle_single_object_allows_point_read_during_paused_write() {
let dir = TempDir::new().unwrap();
let db = std::sync::Arc::new(DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap());
let writer = std::sync::Arc::clone(&db);
let reader = std::sync::Arc::clone(&db);
let (ready_rx, release_tx) = writer.set_runtime_publish_pause();
let (write_done_tx, write_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = writer.upsert_node("Person", "single-object", UpsertNodeOptions::default());
let _ = write_done_tx.send(result);
});
ready_rx.recv().unwrap();
assert!(reader.get_node(1).unwrap().is_none());
assert!(reader.get_node_by_key("Person", "single-object").unwrap().is_none());
assert!(reader.find_existing_node(1, "single-object").unwrap().is_none());
let batch_nodes = reader.get_nodes(&[1]).unwrap();
assert!(batch_nodes[0].is_none());
let batch_keys = reader
.get_nodes_by_keys(&[NodeKeyQuery { label: "Person".to_string(), key: "single-object".to_string() }])
.unwrap();
assert!(batch_keys[0].is_none());
release_tx.send(()).unwrap();
let id = write_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
assert_eq!(id, 1);
assert_eq!(reader.get_node(id).unwrap().unwrap().key, "single-object");
assert_eq!(
reader.get_node_by_key("Person", "single-object").unwrap().unwrap().id,
id
);
assert_eq!(
reader
.find_existing_node(1, "single-object")
.unwrap()
.map(|(node_id, _)| node_id),
Some(id)
);
db.close().unwrap();
}
#[test]
fn test_coordinator_sequences_same_key_upserts_to_one_id() {
let dir = TempDir::new().unwrap();
let db = std::sync::Arc::new(DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap());
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let writer_a = std::sync::Arc::clone(&db);
let barrier_a = std::sync::Arc::clone(&barrier);
let handle_a = std::thread::spawn(move || {
barrier_a.wait();
writer_a.upsert_node("Person", "same-key", UpsertNodeOptions::default())
});
let writer_b = std::sync::Arc::clone(&db);
let barrier_b = std::sync::Arc::clone(&barrier);
let handle_b = std::thread::spawn(move || {
barrier_b.wait();
writer_b.upsert_node("Person", "same-key", UpsertNodeOptions::default())
});
barrier.wait();
let id_a = handle_a.join().unwrap().unwrap();
let id_b = handle_b.join().unwrap().unwrap();
assert_eq!(id_a, 1);
assert_eq!(id_b, 1);
assert_eq!(db.get_node_by_key("Person", "same-key").unwrap().unwrap().id, 1);
db.close().unwrap();
}
#[test]
fn test_coordinator_sequences_unique_edge_upserts_to_one_id() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let db = std::sync::Arc::new(DatabaseEngine::open(dir.path(), &opts).unwrap());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let writer_a = std::sync::Arc::clone(&db);
let barrier_a = std::sync::Arc::clone(&barrier);
let handle_a = std::thread::spawn(move || {
barrier_a.wait();
writer_a.upsert_edge(a, b, "RELATED_TO", UpsertEdgeOptions::default())
});
let writer_b = std::sync::Arc::clone(&db);
let barrier_b = std::sync::Arc::clone(&barrier);
let handle_b = std::thread::spawn(move || {
barrier_b.wait();
writer_b.upsert_edge(a, b, "RELATED_TO", UpsertEdgeOptions::default())
});
barrier.wait();
let id_a = handle_a.join().unwrap().unwrap();
let id_b = handle_b.join().unwrap().unwrap();
assert_eq!(id_a, 1);
assert_eq!(id_b, 1);
assert_eq!(db.get_edge_by_triple(a, b, "RELATED_TO").unwrap().unwrap().id, 1);
db.close().unwrap();
}
#[test]
fn test_core_write_queue_capacity_blocks_pre_admission_until_slot_frees() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.set_core_write_queue_capacity_for_test(1);
let writer_a = db.clone();
let writer_b = db.clone();
let (ready_rx, release_tx) = writer_a.set_runtime_publish_pause();
let (done_a_tx, done_a_rx) = std::sync::mpsc::sync_channel(1);
let (done_b_tx, done_b_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = done_a_tx.send(writer_a.upsert_node("Person", "queued-a", UpsertNodeOptions::default()));
});
ready_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
std::thread::spawn(move || {
let _ = done_b_tx.send(writer_b.upsert_node("Person", "queued-b", UpsertNodeOptions::default()));
});
assert!(
done_b_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"second writer should still be blocked waiting for queue capacity"
);
release_tx.send(()).unwrap();
assert_eq!(
done_a_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
1
);
assert_eq!(
done_b_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
2
);
db.close().unwrap();
}
#[test]
fn test_core_write_queue_close_rejects_pre_admission_submitter() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.set_core_write_queue_capacity_for_test(1);
let writer_a = db.clone();
let writer_b = db.clone();
let closer = db.clone();
let (ready_rx, release_tx) = writer_a.set_runtime_publish_pause();
let (done_a_tx, done_a_rx) = std::sync::mpsc::sync_channel(1);
let (done_b_tx, done_b_rx) = std::sync::mpsc::sync_channel(1);
let (close_done_tx, close_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = done_a_tx.send(writer_a.upsert_node("Person", "close-head", UpsertNodeOptions::default()));
});
ready_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
std::thread::spawn(move || {
let _ = done_b_tx.send(writer_b.upsert_node("Person", "close-blocked", UpsertNodeOptions::default()));
});
assert!(
done_b_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"blocked writer should still be waiting for queue capacity"
);
std::thread::spawn(move || {
let _ = close_done_tx.send(closer.close());
});
assert!(
close_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"close should wait for the already-admitted head write"
);
release_tx.send(()).unwrap();
assert_eq!(
done_a_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
1
);
assert!(matches!(
done_b_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap(),
Err(EngineError::DatabaseClosed)
));
close_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
let reopened = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert_eq!(reopened.get_node(1).unwrap().unwrap().key, "close-head");
assert!(reopened.get_node_by_key("Person", "close-blocked").unwrap().is_none());
reopened.close().unwrap();
}
#[test]
fn test_coordinator_head_of_line_backpressure_retry_preserves_order() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
memtable_flush_threshold: 0,
memtable_hard_cap_bytes: 0,
max_immutable_memtables: 1,
compact_after_n_flushes: 0,
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(
db.upsert_node("Person", "seed", UpsertNodeOptions::default()).unwrap(),
1
);
db.freeze_memtable().unwrap();
let writer_a = db.clone();
let writer_b = db.clone();
let (ready_rx, release_tx) = db.set_flush_pause();
let (done_a_tx, done_a_rx) = std::sync::mpsc::sync_channel(1);
let (done_b_tx, done_b_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = done_a_tx.send(writer_a.upsert_node("Person", "head-a", UpsertNodeOptions::default()));
});
ready_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
std::thread::spawn(move || {
let _ = done_b_tx.send(writer_b.upsert_node("Person", "queued-b", UpsertNodeOptions::default()));
});
assert!(
done_b_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"later queued write must not overtake the head command while it waits on lifecycle progress"
);
release_tx.send(()).unwrap();
assert_eq!(
done_a_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
2
);
assert_eq!(
done_b_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
3
);
assert_eq!(db.get_node_by_key("Person", "head-a").unwrap().unwrap().id, 2);
assert_eq!(db.get_node_by_key("Person", "queued-b").unwrap().unwrap().id, 3);
db.close().unwrap();
}
#[test]
fn test_coordinator_flush_barrier_stays_head_of_line() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.upsert_node("Person", "flush-head", UpsertNodeOptions::default())
.unwrap();
db.freeze_memtable().unwrap();
let flusher = db.clone();
let writer = db.clone();
let (ready_rx, release_tx) = db.set_flush_pause();
let (flush_done_tx, flush_done_rx) = std::sync::mpsc::sync_channel(1);
let (write_done_tx, write_done_rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = flush_done_tx.send(flusher.flush());
});
ready_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
std::thread::spawn(move || {
let _ = write_done_tx.send(writer.upsert_node("Person", "queued-after-flush", UpsertNodeOptions::default()));
});
assert!(
write_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"later queued write must not overtake the head flush barrier"
);
release_tx.send(()).unwrap();
assert!(flush_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap()
.is_some());
assert_eq!(
write_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
2
);
db.close().unwrap();
}
#[test]
fn test_compact_with_progress_waits_for_admitted_work_and_blocks_later_mutations() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: u32::MAX,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
let seg_a = db
.upsert_node("Person", "seg-a", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let seg_b = db
.upsert_node("Person", "seg-b", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let writer_a = db.clone();
let writer_b = db.clone();
let compactor = db.clone();
let (publish_ready_rx, publish_release_tx) = writer_a.set_runtime_publish_pause();
let (write_a_done_tx, write_a_done_rx) = std::sync::mpsc::sync_channel(1);
let (write_b_done_tx, write_b_done_rx) = std::sync::mpsc::sync_channel(1);
let (compact_started_tx, compact_started_rx) = std::sync::mpsc::sync_channel(1);
let (compact_release_tx, compact_release_rx) = std::sync::mpsc::sync_channel(1);
let (compact_done_tx, compact_done_rx) = std::sync::mpsc::sync_channel(1);
let compact_gate_used = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
std::thread::spawn(move || {
let _ = write_a_done_tx.send(writer_a.upsert_node(
"Person",
"ahead-of-compact",
UpsertNodeOptions::default(),
));
});
publish_ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
std::thread::spawn(move || {
let compact_gate_used = std::sync::Arc::clone(&compact_gate_used);
let _ = compact_done_tx.send(compactor.compact_with_progress(|_| {
if !compact_gate_used.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = compact_started_tx.send(());
compact_release_rx.recv().unwrap();
}
true
}));
});
db.wait_for_mutating_barrier_active_for_test();
std::thread::spawn(move || {
let _ = write_b_done_tx.send(writer_b.upsert_node(
"Person",
"behind-compact",
UpsertNodeOptions::default(),
));
});
assert!(
compact_started_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"compact_with_progress must wait for already-admitted queued work ahead of it"
);
assert!(
write_b_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"later mutating admissions must block behind the direct compaction barrier"
);
publish_release_tx.send(()).unwrap();
assert_eq!(
write_a_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
3
);
compact_started_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
assert_eq!(db.get_node(seg_a).unwrap().unwrap().key, "seg-a");
assert_eq!(db.get_node(seg_b).unwrap().unwrap().key, "seg-b");
assert!(
write_b_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"mutating work must stay blocked while compact_with_progress holds the barrier"
);
compact_release_tx.send(()).unwrap();
compact_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap();
assert_eq!(
write_b_done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.unwrap(),
4
);
db.close().unwrap();
}
#[test]
fn test_publish_counters_use_snapshot_only_for_normal_write() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let before = db.published_state();
db.reset_publish_counters_for_test();
let node_id = db
.upsert_node("Person", "snapshot-only", UpsertNodeOptions::default())
.unwrap();
let after = db.published_state();
let counters = db.publish_counter_snapshot_for_test();
assert_eq!(node_id, 1);
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 1);
assert_eq!(counters.rebuild_sources, 0);
assert_eq!(counters.source_rebuilds, 0);
assert!(std::sync::Arc::ptr_eq(&before.view.sources, &after.view.sources));
assert_ne!(before.engine_seq, after.engine_seq);
db.close().unwrap();
}
#[test]
fn test_publish_counters_rebuild_sources_for_flush() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
..DbOptions::default()
};
let db = DatabaseEngine::open(dir.path(), &opts).unwrap();
db.upsert_node("Person", "flush-me", UpsertNodeOptions::default())
.unwrap();
db.reset_publish_counters_for_test();
assert!(db.flush().unwrap().is_some());
let counters = db.publish_counter_snapshot_for_test();
assert_eq!(counters.snapshot_only, 0);
assert!(counters.rebuild_sources >= 1);
assert_eq!(counters.source_rebuilds, counters.rebuild_sources);
db.close().unwrap();
}
#[test]
fn test_publish_counters_rebuild_sources_for_prune_policy_change_and_skip_noop() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let policy = PrunePolicy {
max_age_ms: Some(60_000),
max_weight: Some(0.5),
label: Some("Person".to_string()),
};
db.reset_publish_counters_for_test();
db.set_prune_policy("low-weight", policy.clone()).unwrap();
let counters = db.publish_counter_snapshot_for_test();
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 0);
assert_eq!(counters.rebuild_sources, 1);
assert_eq!(counters.source_rebuilds, 1);
db.reset_publish_counters_for_test();
db.set_prune_policy("low-weight", policy).unwrap();
let counters = db.publish_counter_snapshot_for_test();
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 0);
assert_eq!(counters.rebuild_sources, 0);
assert_eq!(counters.source_rebuilds, 0);
db.close().unwrap();
}
#[test]
fn test_publish_counters_rebuild_sources_for_property_index_change_and_skip_existing() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert(
"status".to_string(),
PropValue::String("active".to_string()),
);
db.upsert_node(
"Person",
"indexed",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
let (ready_rx, release_tx) = db.set_secondary_index_build_pause();
db.reset_publish_counters_for_test();
let info = db
.ensure_node_property_index("Person", "status", SecondaryIndexKind::Equality)
.unwrap();
let counters = db.publish_counter_snapshot_for_test();
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 0);
assert_eq!(counters.rebuild_sources, 1);
assert_eq!(counters.source_rebuilds, 1);
ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
db.reset_publish_counters_for_test();
let existing = db
.ensure_node_property_index("Person", "status", SecondaryIndexKind::Equality)
.unwrap();
assert_eq!(existing.index_id, info.index_id);
let counters = db.publish_counter_snapshot_for_test();
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 0);
assert_eq!(counters.rebuild_sources, 0);
assert_eq!(counters.source_rebuilds, 0);
db.close().unwrap();
}
#[test]
fn test_publish_counters_skip_ingest_mode_bookkeeping() {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
db.reset_publish_counters_for_test();
db.ingest_mode().unwrap();
let counters = db.publish_counter_snapshot_for_test();
assert!(counters.skipped >= 1);
assert_eq!(counters.snapshot_only, 0);
assert_eq!(counters.rebuild_sources, 0);
assert_eq!(counters.source_rebuilds, 0);
db.close().unwrap();
}
#[test]
fn test_property_index_followups_coalesce_while_first_followup_is_in_flight() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node(
"Person",
"coalesce",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
std::fs::remove_file(&sidecar_path).unwrap();
assert!(!sidecar_path.exists());
db.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let (repair_ready_rx, repair_release_tx) = db.set_secondary_index_build_pause();
let (followup_ready_rx, followup_release_tx) = db.set_runtime_publish_pause();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
followup_ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
assert_eq!(db.pending_secondary_index_followup_count_for_test(), 1);
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
assert_eq!(db.pending_secondary_index_followup_count_for_test(), 1);
followup_release_tx.send(()).unwrap();
repair_ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
wait_for_pending_secondary_index_followup_count(&db, 0);
let building = db
.list_node_property_indexes().unwrap()
.into_iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
assert_eq!(building.state, SecondaryIndexState::Building);
repair_release_tx.send(()).unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let repaired_sidecar_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
assert!(repaired_sidecar_path.exists());
db.close().unwrap();
}
#[test]
fn test_targeted_equality_stats_refresh_replaces_reader_after_ready_transition() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let mut node_ids = Vec::new();
for (key, color) in [("red-a", "red"), ("blue-a", "blue"), ("red-b", "red")] {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String(color.to_string()));
let node_id = db
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
node_ids.push(node_id);
}
db.flush().unwrap();
let old_reader = db.segments_for_test()[0].clone();
let segment_id = old_reader.segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let old_component_manifest = read_component_manifest_for_test(&seg_dir);
let packed_core_before = packed_core_snapshot_for_test(&seg_dir);
let base_stats_path = seg_dir.join(crate::planner_stats::PLANNER_STATS_FILENAME);
let base_stats_payload_before = read_external_component_payload_for_test(&base_stats_path);
let old_read_view = db.published_read_view_for_test();
let old_stats_view = Arc::clone(&old_read_view.planner_stats);
assert!(old_reader.planner_stats().unwrap().equality_index_stats.is_empty());
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let new_reader = db.segments_for_test()[0].clone();
assert!(!Arc::ptr_eq(&old_reader, &new_reader));
assert!(new_reader.component_manifest_generation() > old_reader.component_manifest_generation());
let new_component_manifest = read_component_manifest_for_test(&seg_dir);
assert!(new_component_manifest.generation > old_component_manifest.generation);
assert_packed_core_unchanged_for_test(
&seg_dir,
&packed_core_before,
"equality/planner-stats optional refresh",
);
assert_eq!(
new_component_manifest.segment_data_id,
old_component_manifest.segment_data_id
);
assert_eq!(
read_external_component_payload_for_test(&base_stats_path),
base_stats_payload_before,
"optional refresh must not overwrite the base planner_stats.dat path"
);
let stats_record = new_component_manifest
.components
.iter()
.find(|record| record.kind == crate::segment_components::SegmentComponentKind::PlannerStats)
.expect("planner stats record should exist");
let crate::segment_components::ComponentHandleV1::ExternalFile {
relative_path: stats_relative_path,
..
} = &stats_record.handle
else {
panic!("planner stats refresh should use an external file handle");
};
assert!(stats_relative_path.starts_with("planner_stats.g"));
assert_ne!(stats_relative_path, crate::planner_stats::PLANNER_STATS_FILENAME);
assert!(seg_dir.join(stats_relative_path).exists());
let eq_record = new_component_manifest
.components
.iter()
.find(|record| {
record.kind
== crate::segment_components::SegmentComponentKind::NodePropertyEqualityIndex {
index_id: info.index_id,
}
})
.expect("equality sidecar record should exist");
let crate::segment_components::ComponentHandleV1::ExternalFile {
relative_path: eq_relative_path,
..
} = &eq_record.handle
else {
panic!("equality sidecar refresh should use an external file handle");
};
assert!(eq_relative_path.starts_with(&format!(
"secondary_indexes/node_prop_eq_{}.g",
info.index_id
)));
assert!(!seg_dir
.join(format!("secondary_indexes/node_prop_eq_{}.dat", info.index_id))
.exists());
let stats = new_reader.planner_stats().expect("targeted stats should be available");
assert_eq!(
stats.build_kind,
crate::planner_stats::PlannerStatsBuildKind::SecondaryIndexRefresh
);
let equality = stats
.equality_index_stats
.iter()
.find(|stats| stats.index_id == info.index_id)
.expect("target equality stats should be present");
assert_eq!(equality.total_postings, 3);
assert_eq!(equality.value_group_count, 2);
assert!(equality.sidecar_present_at_build);
assert!(old_reader
.planner_stats()
.unwrap()
.equality_index_stats
.iter()
.all(|stats| stats.index_id != info.index_id));
let old_reader_node = old_reader
.get_node(node_ids[0])
.unwrap()
.expect("old reader should keep packed core mapped after refresh");
assert_eq!(old_reader_node.key, "red-a");
assert!(!old_stats_view.equality_index_rollups.contains_key(&info.index_id));
assert!(!old_read_view
.planner_stats
.equality_index_rollups
.contains_key(&info.index_id));
let new_stats_view = db.planner_stats_view_for_test();
assert!(!Arc::ptr_eq(&old_stats_view, &new_stats_view));
assert!(new_stats_view.generation > old_stats_view.generation);
assert!(new_stats_view.equality_index_rollups.contains_key(&info.index_id));
db.close().unwrap();
}
#[test]
fn test_ready_index_adopts_refreshed_sidecar_when_stats_refresh_fails() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let red = PropValue::String("red".to_string());
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node("Person", "stats-fails-adopt", UpsertNodeOptions { props, ..Default::default() })
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let ready_entry =
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let sidecar_path = crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
std::fs::remove_file(&sidecar_path).unwrap();
db.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let missing_reader = db.segments_for_test()[0].clone();
let groups = build_secondary_eq_groups_for_segment(&missing_reader, 1, "color").unwrap();
install_secondary_eq_sidecar(&seg_dir, &ready_entry, &groups).unwrap();
let manifest_after_sidecar = read_component_manifest_for_test(&seg_dir);
assert!(manifest_after_sidecar.generation > missing_reader.component_manifest_generation());
let blocked_stats_path = seg_dir.join(format!(
"planner_stats.g{:016}.dat",
manifest_after_sidecar.generation + 1
));
std::fs::create_dir(&blocked_stats_path).unwrap();
let ready = SecondaryIndexReadyApplied::from_ready_entry(&ready_entry, vec![segment_id]).unwrap();
let manifest_write_lock = {
let core_guard = db.runtime.core.lock().unwrap();
Arc::clone(&core_guard.as_ref().unwrap().manifest_write_lock)
};
let refreshed = refresh_ready_secondary_index_planner_stats(
&db_path,
&manifest_write_lock,
&ready,
&AtomicBool::new(false),
);
assert_eq!(refreshed.len(), 1);
assert_eq!(
read_component_manifest_for_test(&seg_dir).generation,
manifest_after_sidecar.generation,
"the forced stats failure must not be required for sidecar adoption"
);
db.runtime
.republish_secondary_index_state_and_refreshed_stats_if_open(&ready, refreshed);
let adopted_reader = db.segments_for_test()[0].clone();
assert!(adopted_reader.component_manifest_generation() > missing_reader.component_manifest_generation());
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
fn test_optional_refresh_preserves_root_manifest_and_republishes_once() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
for (key, color) in [("refresh-a", "red"), ("refresh-b", "blue")] {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String(color.to_string()));
db.upsert_node("Person", key, UpsertNodeOptions { props, ..Default::default() })
.unwrap();
}
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let ready_entry =
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let before_reader = db.segments_for_test()[0].clone();
let seg_dir = segment_dir(&db_path, before_reader.segment_id);
let before_component_manifest = read_component_manifest_for_test(&seg_dir);
let packed_core_before = packed_core_snapshot_for_test(&seg_dir);
let root_manifest_before = std::fs::read(db_path.join("manifest.current")).unwrap();
let ready = SecondaryIndexReadyApplied::from_ready_entry(
&ready_entry,
vec![before_reader.segment_id],
)
.unwrap();
let manifest_write_lock = {
let core_guard = db.runtime.core.lock().unwrap();
Arc::clone(&core_guard.as_ref().unwrap().manifest_write_lock)
};
let refreshed = refresh_ready_secondary_index_planner_stats(
&db_path,
&manifest_write_lock,
&ready,
&AtomicBool::new(false),
);
assert_eq!(refreshed.len(), 1);
let after_component_manifest = read_component_manifest_for_test(&seg_dir);
assert!(after_component_manifest.generation > before_component_manifest.generation);
assert_packed_core_unchanged_for_test(
&seg_dir,
&packed_core_before,
"targeted planner-stats optional refresh",
);
assert_eq!(
after_component_manifest.segment_data_id,
before_component_manifest.segment_data_id
);
assert_eq!(
std::fs::read(db_path.join("manifest.current")).unwrap(),
root_manifest_before,
"optional refresh must not rewrite the root DB manifest"
);
db.reset_publish_counters_for_test();
db.runtime
.republish_secondary_index_state_and_refreshed_stats_if_open(&ready, refreshed);
let counters = db.publish_counter_snapshot_for_test();
assert_eq!(counters.rebuild_sources, 1);
assert_eq!(counters.source_rebuilds, 1);
let after_reader = db.segments_for_test()[0].clone();
assert!(after_reader.component_manifest_generation() > before_reader.component_manifest_generation());
db.close().unwrap();
}
#[test]
fn test_optional_refresh_adoption_rejects_older_reader_generation() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
db.upsert_node("Person", "stale-adopt", UpsertNodeOptions { props, ..Default::default() })
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let ready_entry =
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let stale_reader = db.segments_for_test()[0].clone();
let ready = SecondaryIndexReadyApplied::from_ready_entry(
&ready_entry,
vec![stale_reader.segment_id],
)
.unwrap();
let manifest_write_lock = {
let core_guard = db.runtime.core.lock().unwrap();
Arc::clone(&core_guard.as_ref().unwrap().manifest_write_lock)
};
let refreshed = refresh_ready_secondary_index_planner_stats(
&db_path,
&manifest_write_lock,
&ready,
&AtomicBool::new(false),
);
assert_eq!(refreshed.len(), 1);
db.runtime
.republish_secondary_index_state_and_refreshed_stats_if_open(&ready, refreshed);
let newer_reader = db.segments_for_test()[0].clone();
assert!(newer_reader.component_manifest_generation() > stale_reader.component_manifest_generation());
db.runtime
.republish_secondary_index_state_and_refreshed_stats_if_open(
&ready,
vec![(stale_reader.segment_id, stale_reader)],
);
assert!(Arc::ptr_eq(&newer_reader, &db.segments_for_test()[0]));
db.close().unwrap();
}
#[test]
fn test_optional_refresh_orphan_files_are_cleaned_on_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let (segment_id, index_id, referenced_eq_path, referenced_stats_path);
let temp_path;
let orphan_stats_path;
let orphan_eq_path;
let packed_core_before;
{
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
db.upsert_node("Person", "cleanup", UpsertNodeOptions { props, ..Default::default() })
.unwrap();
db.flush().unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
index_id = info.index_id;
segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
packed_core_before = packed_core_snapshot_for_test(&seg_dir);
referenced_eq_path =
crate::segment_writer::node_prop_eq_sidecar_path(&seg_dir, info.index_id);
let manifest = read_component_manifest_for_test(&seg_dir);
let stats_record = manifest
.components
.iter()
.find(|record| {
record.kind == crate::segment_components::SegmentComponentKind::PlannerStats
})
.unwrap();
let crate::segment_components::ComponentHandleV1::ExternalFile {
relative_path,
..
} = &stats_record.handle
else {
panic!("planner stats should use external file");
};
referenced_stats_path = seg_dir.join(relative_path);
temp_path = seg_dir.join(".planner_stats.refresh_tmp.test.g0000000000009999.dat");
orphan_stats_path = seg_dir.join("planner_stats.g0000000000009999.dat");
orphan_eq_path = seg_dir
.join(format!("secondary_indexes/node_prop_eq_{index_id}.g0000000000009999.dat"));
std::fs::write(&temp_path, b"temp").unwrap();
std::fs::write(&orphan_stats_path, b"orphan stats").unwrap();
std::fs::write(&orphan_eq_path, b"orphan eq").unwrap();
db.close().unwrap();
}
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert!(!temp_path.exists());
assert!(!orphan_stats_path.exists());
assert!(!orphan_eq_path.exists());
assert!(referenced_eq_path.exists());
assert!(referenced_stats_path.exists());
let seg_dir = segment_dir(&db_path, segment_id);
assert_packed_core_unchanged_for_test(
&seg_dir,
&packed_core_before,
"optional orphan cleanup",
);
assert_eq!(reopened.segments_for_test()[0].segment_id, segment_id);
let ready = reopened
.list_node_property_indexes()
.unwrap()
.into_iter()
.find(|entry| entry.index_id == index_id)
.unwrap();
assert_eq!(ready.state, SecondaryIndexState::Ready);
reopened.close().unwrap();
}
#[test]
fn test_public_index_list_waits_for_ready_publish_after_targeted_stats_refresh() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node("Person", "publish-boundary", UpsertNodeOptions { props, ..Default::default() })
.unwrap();
db.flush().unwrap();
let old_read_view = db.published_read_view_for_test();
assert!(old_read_view.secondary_index_entries.is_empty());
let (build_ready_rx, build_release_tx) = db.set_secondary_index_build_pause();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
build_ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let listed = db.list_node_property_indexes().unwrap();
assert_eq!(listed[0].index_id, info.index_id);
assert_eq!(listed[0].state, SecondaryIndexState::Building);
let (publish_ready_rx, publish_release_tx) = db.set_runtime_publish_pause();
build_release_tx.send(()).unwrap();
publish_ready_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let listed_while_publish_paused = db.list_node_property_indexes().unwrap();
assert_eq!(listed_while_publish_paused[0].index_id, info.index_id);
assert_ne!(listed_while_publish_paused[0].state, SecondaryIndexState::Ready);
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes_before_publish = db.property_query_route_snapshot();
assert_eq!(routes_before_publish.equality_scan_fallback, 1);
assert_eq!(routes_before_publish.equality_index_lookup, 0);
publish_release_tx.send(()).unwrap();
let ready = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
assert!(old_read_view.secondary_index_entries.is_empty());
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes_after_publish = db.property_query_route_snapshot();
assert_eq!(routes_after_publish.equality_scan_fallback, 0);
assert_eq!(routes_after_publish.equality_index_lookup, 1);
db.close().unwrap();
}
#[test]
#[cfg(unix)]
fn test_targeted_stats_refresh_ignores_stale_legacy_tmp_and_publishes_ready_index() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let node_id = db
.upsert_node("Person", "stats-failure-ready", UpsertNodeOptions { props, ..Default::default() })
.unwrap();
db.flush().unwrap();
let segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let tmp_path = seg_dir.join("planner_stats.tmp");
std::fs::write(&tmp_path, b"stale tmp").unwrap();
let mut perms = std::fs::metadata(&tmp_path).unwrap().permissions();
perms.set_mode(0o444);
std::fs::set_permissions(&tmp_path, perms).unwrap();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let ready = wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.state, SecondaryIndexState::Ready);
assert!(ready.last_error.is_none());
assert!(tmp_path.exists());
db.reset_property_query_routes();
assert_eq!(db.find_nodes("Person", "color", &red).unwrap(), vec![node_id]);
let routes = db.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
let readers = db.segments_for_test();
let stats = readers[0].planner_stats().unwrap();
assert!(stats
.equality_index_stats
.iter()
.any(|stats| stats.index_id == info.index_id && stats.sidecar_present_at_build));
db.close().unwrap();
}
#[test]
fn test_targeted_range_stats_refresh_writes_minimal_stats_when_missing() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
for (key, score) in [("score-a", 10), ("score-b", 20), ("score-c", 30)] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(score));
db.upsert_node("Person", key, UpsertNodeOptions { props, ..Default::default() })
.unwrap();
}
db.flush().unwrap();
let segment_id = db.segments_for_test()[0].segment_id;
let seg_dir = segment_dir(&db_path, segment_id);
let stats_path = seg_dir.join(crate::planner_stats::PLANNER_STATS_FILENAME);
std::fs::remove_file(&stats_path).unwrap();
let packed_core_before = packed_core_snapshot_for_test(&seg_dir);
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
assert_packed_core_unchanged_for_test(
&seg_dir,
&packed_core_before,
"range/planner-stats optional refresh",
);
let manifest = read_component_manifest_for_test(&seg_dir);
let range_record = manifest
.components
.iter()
.find(|record| {
record.kind
== crate::segment_components::SegmentComponentKind::NodePropertyRangeIndex {
index_id: info.index_id,
}
})
.expect("range sidecar record should exist");
assert!(
matches!(
&range_record.handle,
crate::segment_components::ComponentHandleV1::ExternalFile { .. }
),
"range optional refresh should publish an external sidecar"
);
let reader = db.segments_for_test()[0].clone();
let stats = reader.planner_stats().expect("minimal targeted stats should load");
assert_eq!(
stats.build_kind,
crate::planner_stats::PlannerStatsBuildKind::SecondaryIndexRefresh
);
assert!(!stats.general_property_stats_complete);
assert_eq!(stats.general_property_sampled_node_count, 0);
assert!(stats.property_stats.is_empty());
assert_eq!(stats.node_count, 3);
assert_eq!(stats.node_label_stats.len(), 1);
let range = stats
.range_index_stats
.iter()
.find(|stats| stats.index_id == info.index_id)
.expect("target range stats should be present");
assert_eq!(range.total_entries, 3);
assert!(range.sidecar_present_at_build);
db.close().unwrap();
}
#[test]
fn test_targeted_range_stats_refresh_writes_minimal_stats_when_corrupt() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
for (key, score) in [("corrupt-a", 10), ("corrupt-b", 20), ("corrupt-c", 30)] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(score));
db.upsert_node("Person", key, UpsertNodeOptions { props, ..Default::default() })
.unwrap();
}
db.flush().unwrap();
let segment_id = db.segments_for_test()[0].segment_id;
let stats_path =
segment_dir(&db_path, segment_id).join(crate::planner_stats::PLANNER_STATS_FILENAME);
std::fs::write(&stats_path, b"corrupt planner stats").unwrap();
let info = db
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let reader = db.segments_for_test()[0].clone();
let stats = reader.planner_stats().expect("corrupt stats should be replaced");
assert_eq!(
stats.build_kind,
crate::planner_stats::PlannerStatsBuildKind::SecondaryIndexRefresh
);
assert!(!stats.general_property_stats_complete);
assert!(stats.property_stats.is_empty());
assert_eq!(stats.node_count, 3);
assert!(stats
.range_index_stats
.iter()
.any(|stats| stats.index_id == info.index_id && stats.sidecar_present_at_build));
db.close().unwrap();
}
#[test]
fn test_targeted_stats_refresh_drops_stale_declared_index_blocks() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
for (key, color, tier) in [
("n-a", "red", "gold"),
("n-b", "blue", "silver"),
("n-c", "red", "gold"),
] {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String(color.to_string()));
props.insert("tier".to_string(), PropValue::String(tier.to_string()));
db.upsert_node("Person", key, UpsertNodeOptions { props, ..Default::default() })
.unwrap();
}
db.flush().unwrap();
let color = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, color.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, color.index_id, SecondaryIndexState::Ready);
assert!(db.segments_for_test()[0]
.planner_stats()
.unwrap()
.equality_index_stats
.iter()
.any(|stats| stats.index_id == color.index_id));
assert!(db
.drop_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap());
let tier = db
.ensure_node_property_index("Person", "tier", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, tier.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&db, tier.index_id, SecondaryIndexState::Ready);
let stats = db.segments_for_test()[0].planner_stats().unwrap().clone();
assert!(stats
.equality_index_stats
.iter()
.all(|stats| stats.index_id != color.index_id));
assert!(stats
.equality_index_stats
.iter()
.any(|stats| stats.index_id == tier.index_id));
assert!(stats
.declared_indexes
.iter()
.all(|declared| declared.index_id != color.index_id));
db.close().unwrap();
}
#[test]
fn test_targeted_stats_refresh_skips_obsolete_segments_before_write_and_swap() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
for segment in 0..2 {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
db.upsert_node(
"Person",
&format!("race-{segment}"),
UpsertNodeOptions { props, ..Default::default() },
)
.unwrap();
db.flush().unwrap();
}
let old_segments = db.segments_for_test();
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let ready_entry =
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let ready = SecondaryIndexReadyApplied::from_ready_entry(
&ready_entry,
old_segments.iter().map(|segment| segment.segment_id).collect(),
)
.unwrap();
db.compact().unwrap();
let current_segment = db.segments_for_test()[0].clone();
let manifest_write_lock = {
let core_guard = db.runtime.core.lock().unwrap();
Arc::clone(&core_guard.as_ref().unwrap().manifest_write_lock)
};
let refreshed = refresh_ready_secondary_index_planner_stats(
&db_path,
&manifest_write_lock,
&ready,
&AtomicBool::new(false),
);
assert!(refreshed.is_empty());
db.runtime
.republish_secondary_index_state_and_refreshed_stats_if_open(
&ready,
vec![(old_segments[0].segment_id, old_segments[0].clone())],
);
assert!(Arc::ptr_eq(¤t_segment, &db.segments_for_test()[0]));
db.close().unwrap();
}
#[test]
fn test_secondary_index_non_ready_finalize_outcomes_do_not_request_stats_refresh() {
fn finalize_eq_for_status(status: SecondaryEqCoverageStatus) -> SecondaryEqFinalizeOutcome {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let entry = SecondaryIndexManifestEntry {
index_id: 991,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "color".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Building,
last_error: None,
};
db.with_runtime_manifest_write(|manifest| {
manifest.secondary_indexes.push(entry.clone());
Ok(())
})
.unwrap();
db.rebuild_secondary_index_catalog().unwrap();
let snapshot = SecondaryEqBuildSnapshot {
dense_config: None,
target: SecondaryIndexTargetDiscriminant::Node,
target_label_id: 1,
prop_key: "color".to_string(),
segment_ids: Vec::new(),
segment_infos: Vec::new(),
secondary_indexes: vec![entry.clone()],
};
let outcome = {
let core_guard = db.runtime.core.lock().unwrap();
let core = core_guard.as_ref().unwrap();
finalize_secondary_eq_build_snapshot(
&core.db_dir,
&core.manifest_write_lock,
&core.secondary_index_catalog,
&core.secondary_index_entries,
&core.next_node_id_seen,
&core.next_edge_id_seen,
&core.engine_seq_seen,
&core.label_catalog,
entry.index_id,
&snapshot,
&status,
)
.unwrap()
};
db.close().unwrap();
outcome
}
for status in [
SecondaryEqCoverageStatus::Incomplete,
SecondaryEqCoverageStatus::Failed("injected failure".to_string()),
SecondaryEqCoverageStatus::Cancelled,
] {
assert!(!matches!(
finalize_eq_for_status(status),
SecondaryEqFinalizeOutcome::ReadyApplied(_)
));
}
fn finalize_range_for_status(
status: SecondaryRangeCoverageStatus,
) -> SecondaryRangeFinalizeOutcome {
let dir = TempDir::new().unwrap();
let db = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let entry = SecondaryIndexManifestEntry {
index_id: 992,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Range,
state: SecondaryIndexState::Building,
last_error: None,
};
db.with_runtime_manifest_write(|manifest| {
manifest.secondary_indexes.push(entry.clone());
Ok(())
})
.unwrap();
db.rebuild_secondary_index_catalog().unwrap();
let snapshot = SecondaryRangeBuildSnapshot {
dense_config: None,
target: SecondaryIndexTargetDiscriminant::Node,
target_label_id: 1,
prop_key: "score".to_string(),
segment_ids: Vec::new(),
segment_infos: Vec::new(),
secondary_indexes: vec![entry.clone()],
};
let outcome = {
let core_guard = db.runtime.core.lock().unwrap();
let core = core_guard.as_ref().unwrap();
finalize_secondary_range_build_snapshot(
&core.db_dir,
&core.manifest_write_lock,
&core.secondary_index_catalog,
&core.secondary_index_entries,
&core.next_node_id_seen,
&core.next_edge_id_seen,
&core.engine_seq_seen,
&core.label_catalog,
entry.index_id,
&snapshot,
&status,
)
.unwrap()
};
db.close().unwrap();
outcome
}
for status in [
SecondaryRangeCoverageStatus::Incomplete,
SecondaryRangeCoverageStatus::Failed("injected failure".to_string()),
SecondaryRangeCoverageStatus::Cancelled,
] {
assert!(!matches!(
finalize_range_for_status(status),
SecondaryRangeFinalizeOutcome::ReadyApplied(_)
));
}
}
#[test]
fn test_targeted_stats_refresh_swaps_only_affected_reader_arcs() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(
&db_path,
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
for segment in 0..2 {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
db.upsert_node(
"Person",
&format!("affected-{segment}"),
UpsertNodeOptions { props, ..Default::default() },
)
.unwrap();
db.flush().unwrap();
}
let info = db
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let ready_entry =
wait_for_published_property_index_state(&db, info.index_id, SecondaryIndexState::Ready);
let before = db.segments_for_test();
let ready = SecondaryIndexReadyApplied::from_ready_entry(
&ready_entry,
before.iter().map(|segment| segment.segment_id).collect(),
)
.unwrap();
let refreshed_segment_id = before[0].segment_id;
let manifest = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
let refreshed_info = manifest
.segments
.iter()
.find(|segment| segment.id == refreshed_segment_id)
.unwrap();
let refreshed_reader = Arc::new(
SegmentReader::open_with_info(
&segment_dir(&db_path, refreshed_segment_id),
refreshed_info,
manifest.dense_vector.as_ref(),
&manifest.secondary_indexes,
)
.unwrap(),
);
db.runtime
.republish_secondary_index_state_and_refreshed_stats_if_open(
&ready,
vec![(refreshed_segment_id, refreshed_reader)],
);
let after = db.segments_for_test();
assert!(!Arc::ptr_eq(&before[0], &after[0]));
assert!(Arc::ptr_eq(&before[1], &after[1]));
db.close().unwrap();
}
#[test]
fn test_open_does_not_backfill_missing_planner_stats() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
db.upsert_node("Person", "missing-stats", UpsertNodeOptions::default())
.unwrap();
db.flush().unwrap();
let segment_id = db.segments_for_test()[0].segment_id;
let stats_path =
segment_dir(&db_path, segment_id).join(crate::planner_stats::PLANNER_STATS_FILENAME);
std::fs::remove_file(&stats_path).unwrap();
assert!(!stats_path.exists());
db.close().unwrap();
}
let reopened = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let reader = reopened.segments_for_test()[0].clone();
assert!(reader.planner_stats().is_none());
let stats_path =
segment_dir(&db_path, reader.segment_id).join(crate::planner_stats::PLANNER_STATS_FILENAME);
assert!(!stats_path.exists());
reopened.close().unwrap();
}