use std::sync::Arc;
use crate::graph::dir_graph::DirGraph;
use crate::graph::durability::{ensure_save_target_recovered, DurableOpenError};
#[derive(Debug)]
pub enum SaveError {
Io(String),
Refused(String),
}
impl std::fmt::Display for SaveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(m) | Self::Refused(m) => f.write_str(m),
}
}
}
impl std::error::Error for SaveError {}
impl From<DurableOpenError> for SaveError {
fn from(error: DurableOpenError) -> Self {
match error {
DurableOpenError::Io(message) | DurableOpenError::Replay(message) => Self::Io(message),
DurableOpenError::Refused(message) => Self::Refused(message),
}
}
}
pub(crate) fn ensure_target_recovered(graph: &Arc<DirGraph>, path: &str) -> Result<(), SaveError> {
ensure_save_target_recovered(std::path::Path::new(path), graph.checkpoint_lsn)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datatypes::Value;
use crate::graph::durability::{self, checkpoint_prologue};
use crate::graph::io::file::{load_file, save_graph};
use crate::graph::storage::GraphRead;
use crate::graph::wal::{wal_path, DurabilityLevel, MutationOp, SyncMode, Wal, WalFrame};
use std::path::Path;
fn person_frame(lsn: u64, age: i64) -> WalFrame {
WalFrame {
lsn,
ops: vec![MutationOp::UpsertNode {
node_type: "Person".into(),
id: Value::Int64(1),
title: Value::String("Alice".into()),
properties: vec![("age".to_string(), Value::Int64(age))],
}],
}
}
fn graph_with_person(age: i64) -> Arc<DirGraph> {
let mut graph = Arc::new(DirGraph::new());
crate::graph::mutation::wal_replay::apply_frames(
crate::graph::handle::make_dir_graph_mut(&mut graph),
&[person_frame(1, age)],
0,
)
.unwrap();
graph
}
fn age_of(graph: &mut Arc<DirGraph>) -> Option<Value> {
let dir = crate::graph::handle::make_dir_graph_mut(graph);
let idx = dir.lookup_by_id("Person", &Value::Int64(1))?;
dir.graph
.node_view(idx)
.and_then(|n| n.get_field_ref("age").map(|c| c.into_owned()))
}
#[test]
fn a_save_that_would_strand_frames_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("graph.kgl");
let path_str = path.to_string_lossy().into_owned();
let mut seeded = graph_with_person(1);
crate::graph::handle::make_dir_graph_mut(&mut seeded).checkpoint_lsn = 1;
save_graph(&mut seeded, &path_str).unwrap();
Wal::open(wal_path(&path), SyncMode::Barrier)
.unwrap()
.append(&person_frame(2, 2))
.unwrap();
let mut loaded = load_file(&path_str).unwrap();
crate::graph::mutation::wal_replay::apply_frames(
crate::graph::handle::make_dir_graph_mut(&mut loaded),
&[person_frame(3, 3)],
2,
)
.unwrap();
let refusal = match save_graph(&mut loaded, &path_str) {
Err(SaveError::Refused(message)) => message,
other => panic!("a save that would strand committed frames must be refused: {other:?}"),
};
assert!(refusal.contains("graph.kgl-wal"), "{refusal}");
assert!(refusal.contains("'full' or 'normal'"), "{refusal}");
assert!(refusal.contains("move the sidecar aside"), "{refusal}");
let mut untouched = load_file(&path_str).unwrap();
assert_eq!(age_of(&mut untouched), Some(Value::Int64(1)));
let mut recovered = load_file(&path_str).unwrap();
durability::open_log(&mut recovered, &path, DurabilityLevel::Full).unwrap();
assert_eq!(age_of(&mut recovered), Some(Value::Int64(2)));
}
#[test]
fn frames_at_or_below_the_checkpoint_still_save() {
let tmp = tempfile::tempdir().unwrap();
let residue = tmp.path().join("residue.kgl");
let mut folded = graph_with_person(1);
crate::graph::handle::make_dir_graph_mut(&mut folded).checkpoint_lsn = 7;
Wal::open(wal_path(&residue), SyncMode::Barrier)
.unwrap()
.append(&person_frame(7, 9))
.unwrap();
save_graph(&mut folded, &residue.to_string_lossy())
.expect("a frame the checkpoint already folded in is harmless residue");
let ahead = tmp.path().join("ahead.kgl");
let mut same = graph_with_person(1);
crate::graph::handle::make_dir_graph_mut(&mut same).checkpoint_lsn = 7;
Wal::open(wal_path(&ahead), SyncMode::Barrier)
.unwrap()
.append(&person_frame(8, 9))
.unwrap();
assert!(
matches!(
save_graph(&mut same, &ahead.to_string_lossy()),
Err(SaveError::Refused(_))
),
"one frame past the checkpoint would be replayed over this save"
);
}
#[test]
fn a_path_with_no_sidecar_saves() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("fresh.kgl");
save_graph(&mut graph_with_person(1), &path.to_string_lossy()).unwrap();
assert!(path.exists());
}
#[test]
fn a_durable_checkpoint_over_its_own_log_is_unaffected() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("durable.kgl");
let path_str = path.to_string_lossy().into_owned();
let mut graph = graph_with_person(1);
let (mut wal, next_lsn) = durability::open_log(&mut graph, &path, DurabilityLevel::Full)
.unwrap()
.expect("a logging level attaches a log");
wal.append(&person_frame(next_lsn, 2)).unwrap();
let next_lsn = next_lsn + 1;
checkpoint_prologue(
&mut wal,
next_lsn,
crate::graph::handle::make_dir_graph_mut(&mut graph),
)
.unwrap();
save_graph(&mut graph, &path_str).expect("a durable checkpoint must not refuse itself");
let unstamped = tmp.path().join("unstamped.kgl");
let mut skipped = graph_with_person(1);
Wal::open(wal_path(&unstamped), SyncMode::Barrier)
.unwrap()
.append(&person_frame(1, 2))
.unwrap();
assert!(
matches!(
save_graph(&mut skipped, &unstamped.to_string_lossy()),
Err(SaveError::Refused(_))
),
"a save that skips the checkpoint stamp strands the frames it left behind"
);
}
#[test]
fn disk_directories_save_without_a_sidecar_and_refuse_with_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("disk");
let mut graph = graph_with_person(1);
crate::graph::handle::make_dir_graph_mut(&mut graph)
.enable_disk_mode()
.unwrap();
save_graph(&mut graph, &root.to_string_lossy()).expect("a disk publish needs no sidecar");
Wal::open(wal_path(Path::new(&root)), SyncMode::Barrier)
.unwrap()
.append(&person_frame(1, 2))
.unwrap();
assert!(matches!(
save_graph(&mut graph, &root.to_string_lossy()),
Err(SaveError::Refused(_))
));
}
}