use std::io;
use std::path::Path;
use std::sync::Arc;
use crate::graph::dir_graph::DirGraph;
use crate::graph::handle::make_dir_graph_mut;
use crate::graph::mutation::wal_replay::apply_frames;
use crate::graph::storage::recording::wrap_for_durability;
use crate::graph::wal::{recover, wal_path, DurabilityLevel, Wal, WalFrame};
#[derive(Debug)]
pub enum DurableOpenError {
Io(String),
Replay(String),
Refused(String),
}
impl std::fmt::Display for DurableOpenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(m) | Self::Replay(m) | Self::Refused(m) => f.write_str(m),
}
}
}
impl std::error::Error for DurableOpenError {}
pub fn open_log(
graph: &mut Arc<DirGraph>,
checkpoint_path: &Path,
level: DurabilityLevel,
) -> Result<Option<(Wal, u64)>, DurableOpenError> {
let wpath = wal_path(checkpoint_path);
let frames = read_sidecar(&wpath)?;
let checkpoint_lsn = graph.checkpoint_lsn;
if !level.logs() {
if unreplayed(&frames, checkpoint_lsn) {
return Err(DurableOpenError::Refused(format!(
"the write-ahead log at '{}' holds commits this checkpoint does not \
contain, and durability level 'off' would neither replay them nor keep \
them — the next checkpoint would truncate the log and the commits would \
be gone. Open with level 'full' or 'normal' to replay and continue the \
log, {DISCARD_EXIT}",
wpath.display(),
)));
}
return Ok(None);
}
if graph.graph.is_recording() {
return Err(DurableOpenError::Refused(
"this graph is already wrapped for durable capture, which means another \
durable owner (a durable graph handle, or a Session) holds it. Two owners \
over one write-ahead log interleave their log-sequence numbers and each \
checkpoint invalidates the other's replay gate, so the second open is \
refused. Take a non-durable snapshot for reads, or hand ownership over \
instead of duplicating it."
.to_string(),
));
}
let sync = level
.sync_mode()
.expect("level.logs() is true, so sync_mode is Some");
let dir = make_dir_graph_mut(graph);
let max_lsn = apply_frames(dir, &frames, checkpoint_lsn).map_err(DurableOpenError::Replay)?;
wrap_for_durability(dir);
let wal = Wal::open(wpath.clone(), sync).map_err(|e| {
DurableOpenError::Io(format!(
"failed to open the write-ahead log at '{}': {e}",
wpath.display()
))
})?;
Ok(Some((wal, max_lsn + 1)))
}
pub fn checkpoint_prologue(wal: &mut Wal, next_lsn: u64, graph: &mut DirGraph) -> io::Result<()> {
wal.sync()?;
graph.checkpoint_lsn = next_lsn.saturating_sub(1);
Ok(())
}
pub fn checkpoint_epilogue(wal: &mut Wal, graph: &mut DirGraph) -> io::Result<()> {
if let Some(rg) = graph.graph.recording_mut() {
let _ = rg.take_ops();
}
wal.reset()
}
pub fn ensure_recovered(
checkpoint_path: &Path,
checkpoint_lsn: u64,
) -> Result<(), DurableOpenError> {
if let Some(wpath) = unrecovered_sidecar(checkpoint_path, checkpoint_lsn)? {
return Err(DurableOpenError::Refused(format!(
"the write-ahead log at '{}' holds commits this checkpoint does not contain, \
and this open attaches no log — it would neither replay them nor keep them, \
and the first save over this path would strand them in front of a newer \
checkpoint for a later durable open to replay back over it. Open the graph \
through a durable entry point (a durable graph handle, or a Session, at \
level 'full' or 'normal') to replay them first, {DISCARD_EXIT}",
wpath.display(),
)));
}
Ok(())
}
pub fn ensure_save_target_recovered(
checkpoint_path: &Path,
checkpoint_lsn: u64,
) -> Result<(), DurableOpenError> {
if let Some(wpath) = unrecovered_sidecar(checkpoint_path, checkpoint_lsn)? {
return Err(DurableOpenError::Refused(format!(
"the write-ahead log at '{}' holds commits the graph being saved does not \
contain, and saving here would strand them: they sit past this checkpoint's \
log-sequence stamp, so a later durable open would replay them back over the \
state this save is about to write. Open the graph through a durable entry \
point (a durable graph handle, or a Session, at level 'full' or 'normal') to \
replay them first, {DISCARD_EXIT}",
wpath.display(),
)));
}
Ok(())
}
fn unrecovered_sidecar(
checkpoint_path: &Path,
checkpoint_lsn: u64,
) -> Result<Option<std::path::PathBuf>, DurableOpenError> {
let wpath = wal_path(checkpoint_path);
let frames = read_sidecar(&wpath)?;
Ok(unreplayed(&frames, checkpoint_lsn).then_some(wpath))
}
const DISCARD_EXIT: &str = "or move the sidecar aside first to deliberately discard those commits.";
fn read_sidecar(wpath: &Path) -> Result<Vec<WalFrame>, DurableOpenError> {
recover(wpath).map_err(|e| {
DurableOpenError::Io(format!(
"failed to read the write-ahead log at '{}': {e}",
wpath.display()
))
})
}
fn unreplayed(frames: &[WalFrame], checkpoint_lsn: u64) -> bool {
frames.iter().any(|f| f.lsn > checkpoint_lsn)
}
#[cfg(test)]
mod recording_over_a_fork_tests {
use super::*;
use crate::datatypes::Value;
use crate::graph::io::file::{load_file, save_graph};
use crate::graph::session::execute::{execute_mut, ExecuteOptions};
use crate::graph::storage::recording::resolve_ops;
use crate::graph::storage::GraphRead;
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!("query failed: {query}: {e}"));
}
fn people(graph: &DirGraph) -> Vec<(i64, i64)> {
let mut out: Vec<(i64, i64)> = graph
.graph
.node_indices()
.filter_map(|idx| graph.graph.node_view(idx))
.filter_map(
|node| match (node.id().into_owned(), node.get_property_value("age")) {
(Value::Int64(id), Some(Value::Int64(age))) => Some((id, age)),
_ => None,
},
)
.collect();
out.sort();
out
}
#[test]
fn a_durable_write_over_a_held_view_is_logged_and_replays() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("durable.kgl");
let path_str = path.to_string_lossy().to_string();
let mut seed = Arc::new(DirGraph::new());
run(
make_dir_graph_mut(&mut seed),
"CREATE (:Person {id: 1, name: 'Alice', age: 30})",
);
save_graph(&mut seed, &path_str).unwrap();
let mut writer = load_file(&path_str).unwrap();
let view = Arc::clone(&writer);
let checkpoint_state = people(&view);
assert_eq!(checkpoint_state, vec![(1, 30)], "fixture");
let (mut wal, next_lsn) = open_log(&mut writer, &path, DurabilityLevel::Full)
.expect("a full durable open over a clean checkpoint")
.expect("level 'full' must hand back a log");
{
let recording = writer
.graph
.recording()
.expect("open_log must wrap the graph for capture");
assert!(
recording.inner().is_forked(),
"precondition: the held view must have forked the graph *before* the \
capture layer wrapped it, or this is the plain Memory arm again"
);
}
let raw = {
let dir = make_dir_graph_mut(&mut writer);
run(dir, "MATCH (p:Person {id: 1}) SET p.age = 31");
run(dir, "CREATE (:Person {id: 2, name: 'Bob', age: 7})");
assert!(
dir.graph
.recording()
.is_some_and(|rg| rg.inner().is_forked()),
"both writes must stay overlay-expressible under the wrapper, or the \
composition under test flattened before it was measured"
);
dir.graph
.recording_mut()
.expect("the capture layer must survive the writes")
.take_ops()
};
assert!(
!raw.is_empty(),
"a write through Recording(Forked) must reach the capture buffer — an empty \
buffer here is an unlogged commit, i.e. silent data loss on the next crash"
);
let ops = {
let dir = writer.as_ref();
resolve_ops(&raw, &dir.graph, &dir.interner, |idx| {
dir.secondary_label_names(idx)
})
};
wal.append(&WalFrame { lsn: next_lsn, ops }).unwrap();
wal.sync().unwrap();
let live = people(&writer);
assert_eq!(live, vec![(1, 31), (2, 7)], "the writer's own state");
drop(wal);
drop(writer);
let mut recovered = load_file(&path_str).unwrap();
assert_eq!(
people(&recovered),
checkpoint_state,
"non-vacuity: the checkpoint alone must NOT contain the logged write, or \
replay has nothing to prove"
);
open_log(&mut recovered, &path, DurabilityLevel::Full)
.expect("recovery must replay the frame")
.expect("level 'full' must hand back a log");
assert_eq!(
people(&recovered),
live,
"replaying the frame a Recording(Forked) backend produced must reconstruct \
the writer's state exactly"
);
assert_eq!(
people(&view),
checkpoint_state,
"the held view must never have seen the durable writer's overlay"
);
}
}