use super::*;
use crate::graph::dir_graph::DirGraph;
use crate::graph::session::execute::{execute_mut, execute_read, ExecuteOptions};
use std::collections::HashMap;
fn run(graph: &mut DirGraph, query: &str) {
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
execute_mut(graph, query, &opts).unwrap_or_else(|e| panic!("setup query failed: {query}: {e}"));
}
fn fixture_bytes(indexed: bool) -> Vec<u8> {
let mut graph = DirGraph::new();
for i in 0..200u32 {
run(
&mut graph,
&format!(
"CREATE (:Item {{id: {i}, sku: 'sku-{i}', category: 'cat-{}'}})",
i % 8
),
);
}
if indexed {
run(&mut graph, "CREATE INDEX FOR (n:Item) ON (n.category)");
}
let mut arc = Arc::new(graph);
prepare_save(&mut arc);
Arc::make_mut(&mut arc).enable_columnar();
let mut bytes = Vec::new();
write_kgl_to(&arc, &mut bytes).expect("fixture write");
bytes
}
fn item_count(graph: &DirGraph) -> i64 {
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
let result = execute_read(graph, "MATCH (n:Item) RETURN count(n) AS c", &opts)
.expect("count query failed")
.result;
match result.rows[0].first() {
Some(Value::Int64(n)) => *n,
other => panic!("unexpected count cell: {other:?}"),
}
}
fn refusal(result: io::Result<Arc<DirGraph>>, context: &str) -> io::Error {
match result {
Err(error) => error,
Ok(_) => panic!("expected a refusal: {context}"),
}
}
fn estimate_of(bytes: &[u8]) -> LoadMemoryEstimate {
estimate_load_memory_bytes(bytes).expect("estimate")
}
#[test]
fn a_load_under_the_ceiling_is_untouched() {
let bytes = fixture_bytes(true);
let estimate = estimate_of(&bytes);
let no_ceiling = load_kgl_bytes_with(&bytes, &LoadOptions::new().with_max_load_bytes(None))
.unwrap_or_else(|e| panic!("no ceiling must not refuse: {e}"));
assert_eq!(item_count(&no_ceiling), 200);
let generous = LoadOptions::new().with_max_load_bytes(Some(estimate.total_peak_bytes() + 1));
let loaded = load_kgl_bytes_with(&bytes, &generous)
.unwrap_or_else(|e| panic!("a ceiling above the estimate must not refuse: {e}"));
assert_eq!(item_count(&loaded), 200);
}
#[test]
fn a_ceiling_under_the_estimate_refuses_with_the_numbers() {
let bytes = fixture_bytes(true);
let estimate = estimate_of(&bytes);
assert!(
estimate.index_rebuild_bytes > 0,
"fixture must declare an index, or the message's index half is untested"
);
let options = LoadOptions::new().with_max_load_bytes(Some(1024));
let error = refusal(load_kgl_bytes_with(&bytes, &options), "1 KB ceiling");
assert_eq!(error.kind(), io::ErrorKind::OutOfMemory);
let message = error.to_string();
for expected in [
"estimated to peak at",
"1 KB ceiling",
"LoadOptions::max_load_bytes",
"KGLITE_MAX_LOAD_MB",
"Nothing was decompressed",
"node rows",
"declared index(es)",
"held transiently",
"defer_index_rebuild",
"raise the ceiling",
"ESTIMATE",
] {
assert!(
message.contains(expected),
"refusal message is missing {expected:?}:\n{message}"
);
}
assert!(message.contains(&format!("{} node rows", estimate.node_rows)));
}
#[test]
fn deferring_the_rebuild_buys_the_headroom_the_refusal_offers() {
let bytes = fixture_bytes(true);
let estimate = estimate_of(&bytes);
let ceiling = estimate.projected_peak_bytes(true) + 1;
assert!(
ceiling < estimate.projected_peak_bytes(false),
"fixture's index term is too small to separate the two projections"
);
let eager = LoadOptions::new()
.with_defer_index_rebuild(false)
.with_max_load_bytes(Some(ceiling));
assert_eq!(
refusal(
load_kgl_bytes_with(&bytes, &eager),
"eager over the ceiling"
)
.kind(),
io::ErrorKind::OutOfMemory
);
let deferred = LoadOptions::new()
.with_defer_index_rebuild(true)
.with_max_load_bytes(Some(ceiling));
let graph =
load_kgl_bytes_with(&bytes, &deferred).unwrap_or_else(|e| panic!("deferred must fit: {e}"));
assert_eq!(item_count(&graph), 200);
}
#[test]
fn an_already_deferred_refusal_offers_only_the_ceiling() {
let bytes = fixture_bytes(true);
let options = LoadOptions::new()
.with_defer_index_rebuild(true)
.with_max_load_bytes(Some(0));
let message = refusal(
load_kgl_bytes_with(&bytes, &options),
"a zero ceiling refuses everything",
)
.to_string();
assert!(message.contains("already deferred"), "{message}");
assert!(!message.contains("Two ways forward"), "{message}");
}
#[test]
fn the_ceiling_is_checked_before_a_section_is_decompressed() {
let mut bytes = fixture_bytes(false);
let metadata_end =
13 + u32::from_le_bytes([bytes[9], bytes[10], bytes[11], bytes[12]]) as usize;
for byte in bytes.iter_mut().skip(metadata_end) {
*byte = 0xff;
}
let corrupt_error = refusal(load_kgl_bytes(&bytes), "the payload is destroyed");
assert_eq!(
corrupt_error.kind(),
io::ErrorKind::InvalidData,
"without a ceiling this file must fail as corrupt: {corrupt_error}"
);
let options = LoadOptions::new().with_max_load_bytes(Some(0));
assert_eq!(
refusal(
load_kgl_bytes_with(&bytes, &options),
"the ceiling must fire"
)
.kind(),
io::ErrorKind::OutOfMemory,
"the ceiling fired after the decode, not before it"
);
}
#[test]
fn the_file_and_byte_estimators_agree_and_only_the_head_is_read() {
let bytes = fixture_bytes(true);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("graph.kgl");
std::fs::write(&path, &bytes).expect("write fixture");
let path_str = path.to_str().unwrap();
let from_file = estimate_load_memory(path_str).expect("file estimate");
assert_eq!(from_file, estimate_of(&bytes));
let metadata_len =
u32::from_le_bytes([bytes[9], bytes[10], bytes[11], bytes[12]]) as usize + 13;
let truncated = dir.path().join("head-only.kgl");
std::fs::write(&truncated, &bytes[..metadata_len]).expect("write head");
assert_eq!(
estimate_load_memory(truncated.to_str().unwrap()).expect("head-only estimate"),
from_file,
"the estimator read past the metadata block"
);
assert!(load_file(truncated.to_str().unwrap()).is_err());
}
#[test]
fn the_index_term_is_the_only_difference_a_declaration_makes() {
let indexed = estimate_of(&fixture_bytes(true));
let plain = estimate_of(&fixture_bytes(false));
assert_eq!(plain.index_rebuild_bytes, 0);
assert_eq!(plain.declared_indexes, 0);
assert_eq!(indexed.declared_indexes, 1);
assert!(indexed.index_rebuild_bytes > 0);
assert_eq!(indexed.node_rows, 200);
assert_eq!(
indexed.total_settled_bytes() - plain.total_settled_bytes(),
indexed.index_rebuild_bytes,
"the declaration moved something other than the index term"
);
}
#[test]
fn estimating_something_that_is_not_a_portable_kgl_refuses() {
let dir = tempfile::tempdir().expect("tempdir");
let refusal = estimate_load_memory(dir.path().to_str().unwrap())
.expect_err("a directory has no metadata head");
assert_eq!(refusal.kind(), io::ErrorKind::InvalidInput);
assert!(refusal.to_string().contains("disk-mode graph directory"));
let not_kgl = dir.path().join("notes.csv");
std::fs::write(¬_kgl, b"id,name\n1,alice\n").expect("write csv");
let refusal = estimate_load_memory(not_kgl.to_str().unwrap()).expect_err("not a .kgl");
assert_eq!(refusal.kind(), io::ErrorKind::InvalidData);
assert!(refusal.to_string().contains("does not start with"));
let stub = dir.path().join("stub.kgl");
std::fs::write(&stub, b"RG").expect("write stub");
assert_eq!(
estimate_load_memory(stub.to_str().unwrap())
.expect_err("two bytes are not a container")
.kind(),
io::ErrorKind::InvalidData
);
}