use std::io::Read;
use super::error::PersistError;
use super::snapshot::{ReadOptions, read_snapshot_into};
use super::wal::{JournalReader, JournalRecord, TornTail};
use crate::graph::PersistentStorage;
use crate::graph::TraversalGraph;
use crate::graph::hooks::Mark;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum RecoveryTarget {
#[default]
Latest,
CommitSeq(u64),
Time(i64),
Mark(String),
}
impl std::fmt::Display for RecoveryTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecoveryTarget::Latest => f.write_str("latest"),
RecoveryTarget::CommitSeq(seq) => write!(f, "commit {seq}"),
RecoveryTarget::Time(time) => write!(f, "time {}", time_text(*time)),
RecoveryTarget::Mark(name) => write!(f, "mark {name:?}"),
}
}
}
pub(crate) fn civil(micros: i64) -> (i64, i64, i64, i64, i64, i64) {
let secs = micros.div_euclid(1_000_000);
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month, day, rem / 3600, rem / 60 % 60, rem % 60)
}
pub(crate) fn time_text(micros: i64) -> String {
let (year, month, day, hour, minute, second) = civil(micros);
let fraction = micros.rem_euclid(1_000_000);
if fraction == 0 {
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
} else {
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{fraction:06}Z")
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct RecoveryReport {
pub snapshot_commit_seq: u64,
pub commit_seq: u64,
pub committed_at: i64,
pub commits_replayed: u64,
pub marks: Vec<Mark>,
pub torn_tail: Option<TornTail>,
pub reached_end: bool,
}
#[non_exhaustive]
pub struct Recovered<S: PersistentStorage = TraversalGraph> {
pub graph: S,
pub report: RecoveryReport,
}
#[must_use]
pub struct Recovery<'a, S: PersistentStorage = TraversalGraph> {
snapshot: Option<Box<dyn Read + 'a>>,
empty: S,
start: Option<S>,
verify_before_images: bool,
journals: Vec<Box<dyn Read + 'a>>,
target: RecoveryTarget,
options: ReadOptions,
chain: Option<Vec<(u128, u64)>>,
}
impl<S: PersistentStorage> std::fmt::Debug for Recovered<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Recovered")
.field("vertex_count", &self.graph.vertex_count())
.field("edge_count", &self.graph.edge_count())
.field("report", &self.report)
.finish()
}
}
impl Default for Recovery<'_> {
fn default() -> Self {
Self::new()
}
}
impl Recovery<'_> {
pub fn new() -> Self {
Self::new_in(TraversalGraph::new())
}
}
impl<'a, S: PersistentStorage> Recovery<'a, S> {
pub fn new_in(empty: S) -> Self {
Self {
snapshot: None,
empty,
start: None,
verify_before_images: false,
journals: Vec::new(),
target: RecoveryTarget::Latest,
options: ReadOptions::default(),
chain: None,
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn chain(mut self, chain: Vec<(u128, u64)>) -> Self {
self.chain = Some(chain);
self
}
pub fn snapshot(mut self, reader: impl Read + 'a) -> Self {
self.snapshot = Some(Box::new(reader));
self
}
pub(crate) fn start_graph(mut self, graph: S) -> Self {
self.start = Some(graph);
self
}
pub(crate) fn verify_before_images(mut self) -> Self {
self.verify_before_images = true;
self
}
pub fn journal(mut self, reader: impl Read + 'a) -> Self {
self.journals.push(Box::new(reader));
self
}
pub fn target(mut self, target: RecoveryTarget) -> Self {
self.target = target;
self
}
pub fn options(mut self, options: ReadOptions) -> Self {
self.options = options;
self
}
pub fn run(self) -> Result<Recovered<S>, PersistError> {
let Recovery {
snapshot,
empty,
start,
verify_before_images,
journals,
target,
options,
chain,
} = self;
let (mut graph, based) = match (start, snapshot) {
(Some(start), _) => (start, true),
(None, Some(reader)) => (read_snapshot_into(reader, &options, empty)?, true),
(None, None) => {
if empty.vertex_count() != 0 || empty.edge_count() != 0 {
return Err(PersistError::Graph(crate::error::GraphError::unsupported(
"recovering into a storage that holds elements",
)));
}
(empty, false)
}
};
let report = replay_journals(
&mut graph,
based,
journals,
target,
&options,
chain.as_deref(),
verify_before_images,
)?;
Ok(Recovered { graph, report })
}
}
pub(crate) fn recover_into<'a, S: PersistentStorage + ?Sized>(
graph: &mut S,
snapshot: impl Read,
journals: Vec<Box<dyn Read + 'a>>,
target: RecoveryTarget,
options: &ReadOptions,
chain: Option<&[(u128, u64)]>,
) -> Result<RecoveryReport, PersistError> {
super::snapshot::load_snapshot(snapshot, options, graph)?;
replay_journals(graph, true, journals, target, options, chain, false)
}
fn replay_journals<'a, S: PersistentStorage + ?Sized>(
graph: &mut S,
mut based: bool,
journals: Vec<Box<dyn Read + 'a>>,
target: RecoveryTarget,
options: &ReadOptions,
chain: Option<&[(u128, u64)]>,
verify_before_images: bool,
) -> Result<RecoveryReport, PersistError> {
{
let not_reached = |reason: String| PersistError::TargetNotReached {
target: target.to_string(),
reason,
};
let snapshot_seq = if based { graph.last_commit_seq() } else { 0 };
let snapshot_time = if based { graph.last_committed_at() } else { 0 };
match &target {
RecoveryTarget::CommitSeq(seq) if *seq < snapshot_seq => {
return Err(not_reached(format!(
"the snapshot is at commit {snapshot_seq}, after the target"
)));
}
RecoveryTarget::Time(time) if snapshot_seq > 0 && *time < snapshot_time => {
return Err(not_reached(format!(
"the snapshot's last commit is at {snapshot_time}, after the target"
)));
}
_ => {}
}
let mut report = RecoveryReport {
snapshot_commit_seq: snapshot_seq,
commit_seq: snapshot_seq,
committed_at: snapshot_time,
commits_replayed: 0,
marks: Vec::new(),
torn_tail: None,
reached_end: true,
};
let mut stopped = matches!(target, RecoveryTarget::CommitSeq(seq) if seq == snapshot_seq);
let mut mark_found = false;
let journal_count = journals.len();
let depth = options.max_value_depth();
for (index, source) in journals.into_iter().enumerate() {
if stopped {
break;
}
let mut reader = JournalReader::open(source, index, depth)?;
let lineage = if based {
graph.graph_id()
} else {
if reader.first_commit_seq() > 1 {
return Err(PersistError::Gap {
journal: index,
offset: 0,
expected: 1,
found: reader.first_commit_seq(),
});
}
graph.set_graph_id(reader.graph_id());
based = true;
graph.graph_id()
};
let mut limit = u64::MAX;
if reader.graph_id() != lineage {
let position =
|id: u128| chain.and_then(|chain| chain.iter().position(|(g, _)| *g == id));
match (position(reader.graph_id()), position(lineage)) {
(Some(journal_at), Some(graph_at)) if journal_at < graph_at => {
graph.set_graph_id(reader.graph_id());
}
(Some(_), Some(_)) => limit = snapshot_seq,
_ => {
return Err(PersistError::LineageMismatch {
journal: index,
expected: crate::value::uuid::format_canonical(lineage).to_string(),
found: crate::value::uuid::format_canonical(reader.graph_id())
.to_string(),
});
}
}
}
if let Some(chain) = chain
&& let Some((_, valid)) = chain.iter().find(|(g, _)| *g == reader.graph_id())
{
limit = limit.min(*valid);
}
while let Some((offset, _, record)) = reader.next_record()? {
match record {
JournalRecord::Commit(changes) => {
let seq = changes.commit_seq();
if seq <= snapshot_seq {
continue;
}
if seq > limit {
return Err(PersistError::corrupt(
format!("journal {index}"),
offset,
format!(
"commit {seq} lies beyond the branch point (commit {limit}) \
of its lineage"
),
));
}
let expected = report.commit_seq + 1;
if seq != expected {
return Err(PersistError::Gap {
journal: index,
offset,
expected,
found: seq,
});
}
let beyond = match &target {
RecoveryTarget::CommitSeq(target_seq) => seq > *target_seq,
RecoveryTarget::Time(time) => changes.committed_at() > *time,
_ => false,
};
if beyond {
stopped = true;
break;
}
let verify = verify_before_images;
PersistentStorage::replay(graph, &changes, verify).map_err(|reason| {
let reason = if verify {
format!(
"commit {seq} does not continue the state it is \
replayed onto: {reason}"
)
} else {
format!("commit {seq} cannot be replayed: {reason}")
};
PersistError::corrupt(format!("journal {index}"), offset, reason)
})?;
report.commit_seq = seq;
report.committed_at = changes.committed_at();
report.commits_replayed += 1;
if matches!(target, RecoveryTarget::CommitSeq(target_seq) if target_seq == seq)
{
stopped = true;
break;
}
}
JournalRecord::Mark(mark) => {
let hit =
matches!(&target, RecoveryTarget::Mark(name) if name == mark.name());
let mark_seq = mark.commit_seq();
report.marks.push(mark);
if hit {
if mark_seq < snapshot_seq {
return Err(not_reached(format!(
"the mark points at commit {mark_seq}, before the snapshot \
(commit {snapshot_seq})"
)));
}
if mark_seq != report.commit_seq {
return Err(PersistError::corrupt(
format!("journal {index}"),
offset,
format!(
"the mark points at commit {mark_seq}, but the journal \
is at commit {}",
report.commit_seq
),
));
}
mark_found = true;
stopped = true;
break;
}
}
JournalRecord::Checkpoint { .. } => {}
}
}
if let Some(torn) = reader.torn_tail() {
if index + 1 != journal_count {
return Err(PersistError::corrupt(
format!("journal {index}"),
torn.offset,
"incomplete record in a journal that is not the last one",
));
}
report.torn_tail = Some(torn);
}
}
report.reached_end = !stopped;
match &target {
RecoveryTarget::CommitSeq(seq) if report.commit_seq < *seq => {
return Err(not_reached(format!(
"the journals end at commit {}",
report.commit_seq
)));
}
RecoveryTarget::Mark(_) if !mark_found => {
return Err(not_reached("no such mark in the journals".to_string()));
}
_ => {}
}
Ok(report)
}
}
pub fn recover<S: Read, J: Read>(
snapshot: S,
journals: impl IntoIterator<Item = J>,
target: RecoveryTarget,
) -> Result<Recovered, PersistError> {
let mut recovery = Recovery::new().snapshot(snapshot).target(target);
for journal in journals {
recovery = recovery.journal(journal);
}
recovery.run()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_time_target_displays_as_rfc_3339() {
assert_eq!(
RecoveryTarget::Time(1_760_000_000_000_000).to_string(),
"time 2025-10-09T08:53:20Z"
);
assert_eq!(
time_text(1_760_000_000_000_042),
"2025-10-09T08:53:20.000042Z"
);
assert_eq!(time_text(0), "1970-01-01T00:00:00Z");
assert_eq!(time_text(-1), "1969-12-31T23:59:59.999999Z");
}
#[test]
fn the_default_replay_recovers_a_recorded_wal_like_the_override() {
use crate::error::GraphError;
use crate::graph::definition::{Definition, QueryDefinition};
use crate::graph::schema::{GraphSchema, PropertySchema};
use crate::graph::{GraphStorage, Transactional};
use crate::persist::encoder::tests::fingerprint;
use crate::persist::snapshot::{WriteOptions, write_packed};
use crate::persist::{Durability, Journal, SharedBuffer};
use crate::value::PropertyMap;
let mut live = TraversalGraph::tinkerpop_modern();
let mut snapshot = Vec::new();
write_packed(&live, &mut snapshot, &WriteOptions::new()).unwrap();
let wal = SharedBuffer::new();
Journal::new(wal.clone(), Durability::EveryCommit)
.attach(&mut live)
.unwrap();
live.transaction(|g| {
g.set_schema(GraphSchema::new().with_vertex("person", PropertySchema::object()))?;
g.set_definition(Definition::query(
"all",
QueryDefinition::new("fn all() { g.v() }"),
))?;
let marko = g
.get_vertex_by_id("1")
.ok_or(GraphError::unsupported("marko"))?;
let lop = g
.get_vertex_by_id("3")
.ok_or(GraphError::unsupported("lop"))?;
g.set_vertex_property(marko, "age".into(), 30i64.into())?;
g.add_vertex_label(marko, "friend".into())?;
let ann = g.add_vertex(None, vec!["person".into()], PropertyMap::default())?;
g.add_edge(None, Some("likes".into()), ann, lop, PropertyMap::default())?;
Ok::<_, GraphError>(())
})
.unwrap();
live.transaction(|g| {
let josh = g
.get_vertex_by_id("4")
.ok_or(GraphError::unsupported("josh"))?;
g.drop_vertex(josh)?;
let created = g.get_edge_by_id("1").ok_or(GraphError::unsupported("e1"))?;
g.drop_edge_property("weight", created)?;
Ok::<_, GraphError>(())
})
.unwrap();
let wal = wal.contents();
let state = |graph: &dyn Fn(&mut Vec<u8>)| {
let mut bytes = Vec::new();
graph(&mut bytes);
fingerprint(&bytes)
};
let expected = state(&|out| {
write_packed(&live, out, &WriteOptions::new()).unwrap();
});
for verify in [false, true] {
let run = |recovery: Recovery<'_, Box<TraversalGraph>>| {
let recovery = recovery
.snapshot(snapshot.as_slice())
.journal(wal.as_slice());
let recovery = if verify {
recovery.verify_before_images()
} else {
recovery
};
recovery.run().unwrap()
};
let generic = run(Recovery::new_in(Box::new(TraversalGraph::new())));
let mut own = Recovery::new()
.snapshot(snapshot.as_slice())
.journal(wal.as_slice());
if verify {
own = own.verify_before_images();
}
let own = own.run().unwrap();
assert_eq!(generic.report, own.report, "verify {verify}");
assert_eq!(generic.report.commits_replayed, 2);
let generic_state = state(&|out| {
write_packed(&*generic.graph, out, &WriteOptions::new()).unwrap();
});
let own_state = state(&|out| {
write_packed(&own.graph, out, &WriteOptions::new()).unwrap();
});
assert_eq!(generic_state, expected, "verify {verify}");
assert_eq!(own_state, expected, "verify {verify}");
assert_eq!(generic.graph.get_schema(), live.get_schema());
assert_eq!(generic.graph.definitions().count(), 1);
assert_eq!(generic.graph.last_commit_seq(), live.last_commit_seq());
assert_eq!(generic.graph.id_sequences(), live.id_sequences());
}
}
}