use super::super::error::PersistError;
use super::super::manifest::is_element_kind;
use super::super::recover::{Recovery, RecoveryTarget};
use super::super::snapshot::{SnapshotInfo, WriteOptions};
use super::super::wal::{JournalReader, JournalRecord, WAL_HEADER_BYTES, WalHeader};
use super::files::{
self, DirPath, GraphFile, Intent, LineageEntry, SNAPSHOTS_DIR, WAL_DIR, list_snapshots,
list_wal, read_bounded, read_graph_file, remove_path, safe_relative, snapshot_name, sync_dir,
wal_after, wal_name, write_atomic, write_graph_file,
};
use super::snapshot_dir::{DirSnapshotReader, read_manifest};
use super::wal_files::{open_segment_reader, read_marks, scan_marks, write_marks};
use crate::graph::PersistentStorage;
use crate::graph::hooks::Mark;
use crate::persist::StoreFailure;
use crate::value::physical;
use crate::value::{ElementProperty, PropertyMap};
pub(crate) const ATTIC_DIR: &str = "attic";
const ATTIC_FILE: &str = "ATTIC";
pub(crate) const REMOVING_PREFIX: &str = "removing-";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum RollbackMode {
#[default]
Move,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RollbackReport {
pub target: u64,
pub previous_commit_seq: u64,
pub graph_id: u128,
pub snapshots_moved: Vec<u64>,
pub segments_moved: usize,
pub attic: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ForkReport {
pub snapshot: SnapshotInfo,
pub marks_not_carried: Vec<Mark>,
}
impl std::fmt::Display for ForkReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
"Forked at commit {} (new graph {}, parent {})",
self.snapshot.commit_seq,
crate::value::uuid::format_canonical(self.snapshot.graph_id),
crate::value::uuid::format_canonical(self.snapshot.parent_graph_id),
)?;
write_marks_not_carried(f, &self.marks_not_carried)
}
}
pub(super) fn write_marks_not_carried(
f: &mut std::fmt::Formatter<'_>,
marks: &[Mark],
) -> std::fmt::Result {
for mark in marks {
writeln!(
f,
" mark not carried: {} @ commit {}",
mark.name(),
mark.commit_seq()
)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AtticEntry {
pub id: String,
pub graph_id: u128,
pub new_graph_id: u128,
pub target: u64,
pub last_commit_seq: u64,
pub time: i64,
pub reason: String,
pub base_snapshot: u64,
pub snapshots: Vec<u64>,
pub segments: Vec<u64>,
pub bytes: u64,
}
fn compact_time(micros: i64) -> String {
let (year, month, day, hour, minute, second) = super::super::recover::civil(micros);
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
}
pub(super) fn tree_bytes(path: &DirPath) -> u64 {
if path.kind() == Some(files::EntryKind::File) {
return path.len().unwrap_or(0);
}
let Ok(entries) = path.list() else {
return 0;
};
entries
.into_iter()
.map(|(name, kind)| match kind {
files::EntryKind::Dir => tree_bytes(&path.join(&name)),
_ => path.join(&name).len().unwrap_or(0),
})
.sum()
}
fn hex_id(id: u128) -> String {
format!("{id:032x}")
}
fn parse_id(text: &str) -> Option<u128> {
(text.len() == 32)
.then(|| u128::from_str_radix(text, 16).ok())
.flatten()
}
pub(super) fn hex_bytes(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub(super) fn parse_hex_bytes(text: &str) -> Option<Vec<u8>> {
if !text.len().is_multiple_of(2) {
return None;
}
(0..text.len())
.step_by(2)
.map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok())
.collect()
}
fn flat_json(pairs: &[(&str, String)]) -> String {
let mut object = PropertyMap::default();
for (key, value) in pairs {
object.insert(*key, ElementProperty::from(value.as_str()));
}
physical::to_json_text(&ElementProperty::Object(Box::new(object))).unwrap_or_default()
}
fn read_flat_json(text: &str) -> Result<Vec<(String, String)>, String> {
let value = physical::lift_untyped(physical::read_json(text, 8).map_err(|e| e.to_string())?);
let ElementProperty::Object(object) = value else {
return Err("not a JSON object".into());
};
Ok(object
.iter()
.filter_map(|(k, v)| match v {
ElementProperty::String(s) => Some((k.to_string(), s.to_string())),
_ => None,
})
.collect())
}
fn get<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
fn param<'a>(intent: &'a Intent, key: &str) -> Result<&'a str, PersistError> {
intent.param(key).ok_or_else(|| {
PersistError::corrupt(
files::INTENT_FILE,
0,
format!("the {} intent has no \"{key}\"", intent.op),
)
})
}
fn param_u64(intent: &Intent, key: &str) -> Result<u64, PersistError> {
param(intent, key)?.parse().map_err(|_| {
PersistError::corrupt(files::INTENT_FILE, 0, format!("\"{key}\" is not a number"))
})
}
fn param_id(intent: &Intent, key: &str) -> Result<u128, PersistError> {
parse_id(param(intent, key)?).ok_or_else(|| {
PersistError::corrupt(
files::INTENT_FILE,
0,
format!("\"{key}\" is not a lineage id"),
)
})
}
fn param_path(dir: &DirPath, intent: &Intent, key: &str) -> Result<DirPath, PersistError> {
let relative = param(intent, key)?;
safe_relative(dir, relative).ok_or_else(|| {
PersistError::corrupt(files::INTENT_FILE, 0, format!("unsafe path {relative:?}"))
})
}
fn io(context: &'static str) -> impl Fn(std::io::Error) -> PersistError {
move |err| PersistError::io(context, err)
}
struct Cut {
segment: (u64, DirPath),
offset: u64,
}
fn find_cut(dir: &DirPath, target: u64) -> Result<Option<Cut>, PersistError> {
let segments = list_wal(dir)?;
let start = wal_after(&segments, target)
.first()
.map(|(first, _)| *first);
for (first, path) in &segments {
if start.is_some_and(|start| *first < start) {
continue;
}
let mut reader = JournalReader::open(
open_segment_reader(path, None)?,
0,
crate::value::depth::DEFAULT_MAX_VALUE_DEPTH,
)?;
while let Some((offset, _, record)) = reader.next_record()? {
if let JournalRecord::Commit(changes) = record
&& changes.commit_seq() > target
{
return Ok(Some(Cut {
segment: (*first, path.clone()),
offset,
}));
}
}
}
Ok(None)
}
fn split_tail(
split: &DirPath,
offset: u64,
first_moved: u64,
time: i64,
old_id: u128,
) -> Result<Vec<u8>, PersistError> {
let lineage = split
.read_range(0, WAL_HEADER_BYTES)
.ok()
.and_then(|head| WalHeader::decode(&head).ok())
.map_or(old_id, |header| header.graph_id);
let mut bytes = WalHeader {
graph_id: lineage,
first_commit_seq: first_moved,
created_at: time,
}
.encode();
let tail = files::read_bounded(split, u64::MAX).map_err(io("reading the split WAL segment"))?;
let start = (offset as usize).min(tail.len());
bytes.extend_from_slice(&tail[start..]);
Ok(bytes)
}
fn param_lines<'a>(intent: &'a Intent, key: &str) -> impl Iterator<Item = &'a str> {
intent
.param(key)
.unwrap_or_default()
.split('\n')
.filter(|line| !line.is_empty())
}
fn prefix_end(entry: &AtticEntry) -> u64 {
entry
.segments
.first()
.map_or(entry.target, |first| first.saturating_sub(1))
.min(entry.target)
}
fn plan_entries(
dir: &DirPath,
target: u64,
moving: &[String],
split: Option<&str>,
) -> Result<[Vec<String>; 3], PersistError> {
let (mut rebase, mut copies, mut drop) = (Vec::new(), Vec::new(), Vec::new());
let first_moved = target + 1;
let wal = list_wal(dir)?;
for entry in attic_list(dir)? {
let entry_dir = format!("{ATTIC_DIR}/{}", entry.id);
let needed = prefix_end(&entry);
if needed > target {
if let Some(split) = split {
copies.push(format!(
"{split} {entry_dir}/{WAL_DIR}/{}",
wal_name(first_moved)
));
}
for (first, path) in &wal {
let name = path.relative_to(dir);
if *first <= needed && moving.contains(&name) {
copies.push(format!("{name} {entry_dir}/{WAL_DIR}/{}", wal_name(*first)));
}
}
rebase.push(entry.id.clone());
}
let path = dir.join(&entry_dir);
let mut stale = false;
for (_, snapshot) in entry_snapshots(&path)? {
let Ok((_, manifest)) = read_manifest(&snapshot) else {
continue;
};
for segment in manifest.segments.iter().filter(|s| is_element_kind(s.kind)) {
let Some((holder, _)) = segment.path.split_once('/') else {
continue;
};
let near = snapshot
.parent()
.and_then(|parent| safe_relative(&parent, &segment.path));
let moves = files::parse_seq(holder).is_some_and(|seq| seq > target);
if moves && near.is_some_and(|near| !near.exists()) {
stale = true;
}
}
}
if stale {
for (_, snapshot) in entry_snapshot_dirs(&path)? {
drop.push(snapshot.relative_to(dir));
}
}
}
Ok([rebase, copies, drop])
}
pub(super) fn plan_rollback(
dir: &DirPath,
target: u64,
last_commit: u64,
mode: RollbackMode,
reason: &str,
) -> Result<Intent, PersistError> {
let graph_file = read_graph_file(dir)?.file;
let snapshots = list_snapshots(dir)?;
let Some((base, _)) = snapshots.iter().rev().find(|(seq, _)| *seq <= target) else {
return Err(PersistError::TargetNotReached {
target: format!("commit {target}"),
reason: "no snapshot at or before it (pruned)".into(),
});
};
let Some(cut) = find_cut(dir, target)? else {
return Err(PersistError::store(
"roll the store back",
StoreFailure::NothingAfterTarget,
format!("the WAL holds no commit after commit {target}"),
));
};
let time = crate::graph::undo::now_micros();
let first_moved = target + 1;
let mut named = time;
let attic = loop {
let attic = format!(
"{ATTIC_DIR}/{}-{}",
compact_time(named),
snapshot_name(first_moved)
);
let taken = dir.join(&attic).exists()
|| dir
.join(ATTIC_DIR)
.join(format!(
"{REMOVING_PREFIX}{}-{}",
compact_time(named),
snapshot_name(first_moved)
))
.exists();
if !taken {
break attic;
}
named = named.saturating_add(1_000_000);
};
let relative = |path: &DirPath| path.relative_to(dir);
let mut files = Vec::new();
for (seq, path) in files::list_snapshot_dirs(dir)? {
if seq > target {
files.push(relative(&path));
}
}
let (cut_first, cut_path) = &cut.segment;
let mut params = vec![
("target".to_string(), target.to_string()),
("last_commit".to_string(), last_commit.to_string()),
("base_snapshot".to_string(), base.to_string()),
(
"mode".to_string(),
match mode {
RollbackMode::Move => "move",
RollbackMode::Delete => "delete",
}
.to_string(),
),
("old_graph_id".to_string(), hex_id(graph_file.graph_id)),
(
"new_graph_id".to_string(),
hex_id(crate::value::uuid::new_v7()),
),
("time".to_string(), time.to_string()),
("attic".to_string(), attic),
("reason".to_string(), reason.to_string()),
];
let whole = cut.offset <= WAL_HEADER_BYTES as u64;
for (first, path) in list_wal(dir)? {
if first > *cut_first || (first == *cut_first && (whole || first == first_moved)) {
files.push(relative(&path));
}
}
if !whole {
if *cut_first == first_moved {
let kept = cut_path
.read_range(
WAL_HEADER_BYTES as u64,
(cut.offset - WAL_HEADER_BYTES as u64) as usize,
)
.map_err(io("reading a WAL segment"))?;
params.push(("kept".to_string(), hex_bytes(&kept)));
} else {
params.push(("split".to_string(), relative(cut_path)));
params.push(("split_offset".to_string(), cut.offset.to_string()));
}
}
let split = params
.iter()
.find(|(key, _)| key == "split")
.map(|(_, value)| value.clone());
let [rebase, copies, drop] = plan_entries(dir, target, &files, split.as_deref())?;
for (key, lines) in [("rebase", rebase), ("copies", copies), ("drop", drop)] {
if !lines.is_empty() {
params.push((key.to_string(), lines.join("\n")));
}
}
Ok(Intent {
op: "rollback".into(),
files,
started_at: time,
params,
})
}
pub(super) fn apply_rollback(
dir: &DirPath,
intent: &Intent,
stop_after: Option<usize>,
) -> Result<(), PersistError> {
let target = param_u64(intent, "target")?;
let first_moved = target + 1;
let old_id = param_id(intent, "old_graph_id")?;
let new_id = param_id(intent, "new_graph_id")?;
let time: i64 = param(intent, "time")?.parse().unwrap_or(0);
let delete = param(intent, "mode")? == "delete";
let attic = param_path(dir, intent, "attic")?;
let mut step = 0usize;
let done = |step: &mut usize| {
*step += 1;
stop_after.is_some_and(|stop| *step >= stop)
};
if !delete {
for sub in [SNAPSHOTS_DIR, WAL_DIR] {
attic
.join(sub)
.create_dir_all()
.map_err(io("creating the attic"))?;
}
if let Some(parent) = attic.parent() {
sync_dir(&parent)?;
}
}
if done(&mut step) {
return Ok(());
}
let mut entry_dirs = Vec::new();
for line in param_lines(intent, "copies") {
let unsafe_line =
|| PersistError::corrupt(files::INTENT_FILE, 0, format!("unsafe copy {line:?}"));
let (source, target_name) = line.split_once(' ').ok_or_else(unsafe_line)?;
let to = safe_relative(dir, target_name).ok_or_else(unsafe_line)?;
let from = safe_relative(dir, source).ok_or_else(unsafe_line)?;
let (Some(parent), true) = (to.parent(), target_name.starts_with(ATTIC_DIR)) else {
return Err(unsafe_line());
};
if !entry_dirs
.iter()
.any(|p: &DirPath| p.name() == parent.name())
{
entry_dirs.push(parent.clone());
}
if to.exists() {
continue;
}
let bytes = if intent.param("split") == Some(source) {
split_tail(
&from,
param_u64(intent, "split_offset")?,
first_moved,
time,
old_id,
)?
} else {
read_bounded(&from, u64::MAX).map_err(io("copying a WAL segment to the attic"))?
};
parent
.create_dir_all()
.map_err(io("copying a WAL segment to the attic"))?;
write_atomic(&parent, to.file_name(), &bytes)?;
}
for parent in &entry_dirs {
sync_dir(parent)?;
}
if done(&mut step) {
return Ok(());
}
for relative in param_lines(intent, "drop") {
let path = safe_relative(dir, relative)
.filter(|_| relative.starts_with(ATTIC_DIR))
.ok_or_else(|| {
PersistError::corrupt(files::INTENT_FILE, 0, format!("unsafe path {relative:?}"))
})?;
remove_path(&path).map_err(io("dropping an attic entry's snapshot"))?;
if let Some(parent) = path.parent() {
sync_dir(&parent)?;
}
}
if done(&mut step) {
return Ok(());
}
for id in param_lines(intent, "rebase") {
rebase_entry(dir, id, param(intent, "base_snapshot")?)?;
}
if done(&mut step) {
return Ok(());
}
if intent.param("split").is_some() {
let split = param_path(dir, intent, "split")?;
let offset = param_u64(intent, "split_offset")?;
let len = split.len().map_err(io("reading the split WAL segment"))?;
if len > offset {
if !delete {
let bytes = split_tail(&split, offset, first_moved, time, old_id)?;
write_atomic(&attic.join(WAL_DIR), &wal_name(first_moved), &bytes)?;
}
split
.truncate(offset)
.map_err(io("splitting a WAL segment"))?;
}
}
if done(&mut step) {
return Ok(());
}
for relative in &intent.files {
let source = safe_relative(dir, relative).ok_or_else(|| {
PersistError::corrupt(files::INTENT_FILE, 0, format!("unsafe path {relative:?}"))
})?;
if !source.exists() {
continue;
}
if delete {
remove_path(&source).map_err(io("deleting the rolled-back history"))?;
} else {
source
.rename_to(&attic.join(relative))
.map_err(io("moving the rolled-back history to the attic"))?;
}
if done(&mut step) {
return Ok(());
}
}
for sub in [SNAPSHOTS_DIR, WAL_DIR] {
sync_dir(&dir.join(sub))?;
if !delete {
sync_dir(&attic.join(sub))?;
}
}
if !delete {
let moved_last = param(intent, "last_commit")?.to_string();
let ok = write_atomic(
&attic,
ATTIC_FILE,
flat_json(&[
("graph_id", hex_id(old_id)),
("new_graph_id", hex_id(new_id)),
("target", target.to_string()),
("last_commit", moved_last),
("time", time.to_string()),
("reason", param(intent, "reason")?.to_string()),
("base_snapshot", param(intent, "base_snapshot")?.to_string()),
(
"kept_bytes",
intent
.param("kept")
.map_or(0, |kept| kept.len() / 2)
.to_string(),
),
])
.as_bytes(),
);
ok?;
}
if done(&mut step) {
return Ok(());
}
let marks: Vec<Mark> = read_marks(dir)?
.unwrap_or_else(|| scan_marks(&list_wal(dir).unwrap_or_default(), 4096))
.into_iter()
.filter(|mark| mark.commit_seq() <= target)
.collect();
write_marks(dir, &marks)?;
if done(&mut step) {
return Ok(());
}
let mut graph_file = read_graph_file(dir)?.file;
let head = Some(target + 1);
if graph_file.graph_id != new_id
|| graph_file.wal_head != head
|| graph_file.closed_at.is_some()
{
if graph_file.graph_id != new_id {
graph_file.lineage.insert(
0,
LineageEntry {
graph_id: new_id,
branched_at: target,
time,
},
);
graph_file.graph_id = new_id;
}
graph_file.wal_head = head;
graph_file.closed_at = None;
write_graph_file(dir, &graph_file)?;
}
if done(&mut step) {
return Ok(());
}
let path = dir.join(WAL_DIR).join(wal_name(first_moved));
let mut bytes = WalHeader {
graph_id: new_id,
first_commit_seq: first_moved,
created_at: time,
}
.encode();
if let Some(kept) = intent.param("kept") {
bytes.extend_from_slice(
&parse_hex_bytes(kept).ok_or_else(|| {
PersistError::corrupt(files::INTENT_FILE, 0, "\"kept\" is not hex")
})?,
);
}
if path.len().map_or(true, |len| len < bytes.len() as u64) {
write_atomic(&dir.join(WAL_DIR), &wal_name(first_moved), &bytes)?;
}
sync_dir(&dir.join(WAL_DIR))?;
super::maintenance::remove_unused_holders(dir)?;
Intent::clear(dir)
}
pub(super) fn attic_list(dir: &DirPath) -> Result<Vec<AtticEntry>, PersistError> {
let root = dir.join(ATTIC_DIR);
let mut out = Vec::new();
let Ok(entries) = root.list() else {
return Ok(out);
};
for (name, kind) in entries {
if name.starts_with(REMOVING_PREFIX) || kind != files::EntryKind::Dir {
continue;
}
out.push(read_entry(dir, &name)?);
}
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
pub(super) fn attic_snapshot_dirs(dir: &DirPath) -> Result<Vec<DirPath>, PersistError> {
let mut out = Vec::new();
for entry in attic_list(dir)? {
let path = entry_path(dir, &entry.id)?;
out.extend(entry_snapshots(&path)?.into_iter().map(|(_, path)| path));
}
Ok(out)
}
fn read_entry(dir: &DirPath, id: &str) -> Result<AtticEntry, PersistError> {
let path = entry_path(dir, id)?;
let location = format!("{ATTIC_DIR}/{id}/{ATTIC_FILE}");
let bytes =
read_bounded(&path.join(ATTIC_FILE), 1 << 20).map_err(io("reading an attic entry"))?;
let text =
String::from_utf8(bytes).map_err(|_| PersistError::corrupt(&location, 0, "not UTF-8"))?;
let pairs =
read_flat_json(&text).map_err(|reason| PersistError::corrupt(&location, 0, reason))?;
let number = |key: &str| -> Result<u64, PersistError> {
get(&pairs, key)
.and_then(|v| v.parse().ok())
.ok_or_else(|| PersistError::corrupt(&location, 0, format!("no number \"{key}\"")))
};
let id_of = |key: &str| -> Result<u128, PersistError> {
get(&pairs, key)
.and_then(parse_id)
.ok_or_else(|| PersistError::corrupt(&location, 0, format!("no lineage id \"{key}\"")))
};
let snapshots = entry_snapshots(&path)?
.into_iter()
.map(|(s, _)| s)
.collect();
let segments = entry_wal(&path)?.into_iter().map(|(s, _)| s).collect();
Ok(AtticEntry {
id: id.to_string(),
graph_id: id_of("graph_id")?,
new_graph_id: id_of("new_graph_id")?,
target: number("target")?,
last_commit_seq: number("last_commit")?,
time: get(&pairs, "time")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
reason: get(&pairs, "reason").unwrap_or_default().to_string(),
base_snapshot: number("base_snapshot")?,
snapshots,
segments,
bytes: tree_bytes(&path),
})
}
fn rebase_entry(dir: &DirPath, id: &str, base: &str) -> Result<(), PersistError> {
let Ok(path) = entry_path(dir, id) else {
return Ok(());
};
let location = format!("{ATTIC_DIR}/{id}/{ATTIC_FILE}");
let bytes =
read_bounded(&path.join(ATTIC_FILE), 1 << 20).map_err(io("reading an attic entry"))?;
let text =
String::from_utf8(bytes).map_err(|_| PersistError::corrupt(&location, 0, "not UTF-8"))?;
let mut pairs =
read_flat_json(&text).map_err(|reason| PersistError::corrupt(&location, 0, reason))?;
if get(&pairs, "base_snapshot") == Some(base) {
return Ok(());
}
pairs.retain(|(key, _)| key != "base_snapshot");
pairs.push(("base_snapshot".to_string(), base.to_string()));
let pairs: Vec<(&str, String)> = pairs
.iter()
.map(|(key, value)| (key.as_str(), value.clone()))
.collect();
write_atomic(&path, ATTIC_FILE, flat_json(&pairs).as_bytes())
}
fn entry_snapshots(path: &DirPath) -> Result<Vec<(u64, DirPath)>, PersistError> {
if !path.join(SNAPSHOTS_DIR).exists() {
return Ok(Vec::new());
}
list_snapshots(path)
}
fn entry_snapshot_dirs(path: &DirPath) -> Result<Vec<(u64, DirPath)>, PersistError> {
if !path.join(SNAPSHOTS_DIR).exists() {
return Ok(Vec::new());
}
files::list_snapshot_dirs(path)
}
fn entry_wal(path: &DirPath) -> Result<Vec<(u64, DirPath)>, PersistError> {
if !path.join(WAL_DIR).exists() {
return Ok(Vec::new());
}
list_wal(path)
}
fn entry_path(dir: &DirPath, id: &str) -> Result<DirPath, PersistError> {
if id.contains('/') || id.starts_with(REMOVING_PREFIX) {
return Err(PersistError::store(
"use the attic entry",
StoreFailure::UnknownAtticEntry,
format!("{id:?} is not an attic entry name"),
));
}
let path = safe_relative(&dir.join(ATTIC_DIR), id).ok_or_else(|| {
PersistError::store(
"use the attic entry",
StoreFailure::UnknownAtticEntry,
format!("{id:?} is not an attic entry name"),
)
})?;
if !path.is_dir() {
return Err(PersistError::store(
"use the attic entry",
StoreFailure::UnknownAtticEntry,
format!(
"the store has no attic entry {id:?} (graphersal store attic <dir> lists them)"
),
));
}
Ok(path)
}
pub(super) fn attic_segments(
dir: &DirPath,
id: &str,
) -> Result<(AtticEntry, Vec<(u64, DirPath)>), PersistError> {
let entry = read_entry(dir, id)?;
let segments = entry_wal(&entry_path(dir, id)?)?;
Ok((entry, segments))
}
pub(super) fn attic_remove(dir: &DirPath, id: &str) -> Result<(), PersistError> {
let path = entry_path(dir, id)?;
let doomed = dir.join(ATTIC_DIR).join(format!("{REMOVING_PREFIX}{id}"));
path.rename_to(&doomed)
.map_err(io("removing an attic entry"))?;
sync_dir(&dir.join(ATTIC_DIR))?;
remove_path(&doomed).map_err(io("removing an attic entry"))?;
super::maintenance::remove_unused_holders(dir)?;
Ok(())
}
pub(super) fn remove_interrupted(dir: &DirPath) -> usize {
let mut removed = 0;
let root = dir.join(ATTIC_DIR);
if let Ok(entries) = root.list() {
for (name, _) in entries {
if name.starts_with(REMOVING_PREFIX) && remove_path(&root.join(&name)).is_ok() {
removed += 1;
}
}
}
removed
}
pub(super) fn plan_restore(
dir: &DirPath,
id: &str,
commit_seq: u64,
) -> Result<Intent, PersistError> {
let entry = read_entry(dir, id)?;
let graph_file = read_graph_file(dir)?.file;
let refuse = |cause: StoreFailure, reason: String| {
Err(PersistError::store(
"restore the attic entry",
cause,
reason,
))
};
if graph_file.graph_id != entry.new_graph_id {
let newer = attic_list(dir)
.ok()
.and_then(|entries| {
entries
.into_iter()
.find(|other| other.id != id && other.graph_id == entry.new_graph_id)
})
.map(|other| {
format!(
"restore the newer entry {} first (graphersal store attic <dir> restore {}), \
or ",
other.id, other.id
)
})
.unwrap_or_default();
return refuse(
StoreFailure::AtticLineageChanged,
format!(
"the store's lineage changed since that rollback (another rollback or restore; \
attic entries restore newest first); {newer}fork the entry instead \
(graphersal store attic <dir> fork {id} <new_dir>)"
),
);
}
if commit_seq != entry.target {
return refuse(
StoreFailure::ChangedSinceRollback,
format!(
"the store has new commits since the rollback (it is at commit {commit_seq}, the \
rollback went to {}); fork the entry instead",
entry.target
),
);
}
let kept = read_entry_kept(dir, id)?;
let new_segment = dir.join(WAL_DIR).join(wal_name(entry.target + 1));
if let Ok(len) = new_segment.len()
&& len > WAL_HEADER_BYTES as u64 + kept
{
return refuse(
StoreFailure::ChangedSinceRollback,
"marks were set since the rollback; fork the entry instead".to_string(),
);
}
let entry_dir = format!("{ATTIC_DIR}/{id}");
let mut files = Vec::new();
for (_, path) in entry_snapshot_dirs(&dir.join(&entry_dir))? {
files.push(format!("{SNAPSHOTS_DIR}/{}", path.file_name()));
}
for (first, _) in entry_wal(&dir.join(&entry_dir))? {
if first > entry.target {
files.push(format!("{WAL_DIR}/{}", wal_name(first)));
}
}
let new_lineage = format!("{WAL_DIR}/{}", wal_name(entry.target + 1));
if let Some(taken) = files
.iter()
.find(|name| **name != new_lineage && dir.join(name.as_str()).exists())
{
return refuse(
StoreFailure::ChangedSinceRollback,
format!("the store already holds {taken}; fork the entry instead"),
);
}
Ok(Intent {
op: "attic_restore".into(),
files,
started_at: crate::graph::undo::now_micros(),
params: vec![
("attic".to_string(), entry_dir),
("target".to_string(), entry.target.to_string()),
("old_graph_id".to_string(), hex_id(entry.graph_id)),
("new_graph_id".to_string(), hex_id(entry.new_graph_id)),
],
})
}
fn read_entry_kept(dir: &DirPath, id: &str) -> Result<u64, PersistError> {
let path = entry_path(dir, id)?;
let bytes =
read_bounded(&path.join(ATTIC_FILE), 1 << 20).map_err(io("reading an attic entry"))?;
let pairs = read_flat_json(&String::from_utf8_lossy(&bytes)).unwrap_or_default();
Ok(get(&pairs, "kept_bytes")
.and_then(|v| v.parse().ok())
.unwrap_or(0))
}
pub(super) fn apply_restore(
dir: &DirPath,
intent: &Intent,
stop_after: Option<usize>,
) -> Result<(), PersistError> {
let target = param_u64(intent, "target")?;
let new_id = param_id(intent, "new_graph_id")?;
let attic = param_path(dir, intent, "attic")?;
let mut step = 0usize;
let done = |step: &mut usize| {
*step += 1;
stop_after.is_some_and(|stop| *step >= stop)
};
let new_segment = dir.join(WAL_DIR).join(wal_name(target + 1));
if new_segment.exists() && segment_id(&new_segment) == Some(new_id) {
remove_path(&new_segment).map_err(io("removing the new lineage's WAL segment"))?;
sync_dir(&dir.join(WAL_DIR))?;
}
let at_target = dir.join(SNAPSHOTS_DIR).join(snapshot_name(target));
if at_target.join(super::snapshot_dir::MANIFEST_FILE).exists()
&& super::snapshot_dir::read_listing(&at_target).is_ok_and(|info| info.graph_id == new_id)
{
remove_path(&at_target).map_err(io("removing the new lineage's snapshot"))?;
sync_dir(&dir.join(SNAPSHOTS_DIR))?;
}
if done(&mut step) {
return Ok(());
}
for relative in &intent.files {
let source = safe_relative(&attic, relative).ok_or_else(|| {
PersistError::corrupt(files::INTENT_FILE, 0, format!("unsafe path {relative:?}"))
})?;
if source.exists() {
source
.rename_to(&dir.join(relative))
.map_err(io("moving the attic history back"))?;
}
if done(&mut step) {
return Ok(());
}
}
for sub in [SNAPSHOTS_DIR, WAL_DIR] {
sync_dir(&dir.join(sub))?;
}
let mut graph_file = read_graph_file(dir)?.file;
let head = list_wal(dir)?.last().map(|(first, _)| *first);
if (graph_file.graph_id == new_id && graph_file.lineage.len() > 1)
|| graph_file.wal_head != head
|| graph_file.closed_at.is_some()
{
if graph_file.graph_id == new_id && graph_file.lineage.len() > 1 {
graph_file.lineage.remove(0);
graph_file.graph_id = graph_file.lineage[0].graph_id;
}
graph_file.wal_head = head;
graph_file.closed_at = None;
write_graph_file(dir, &graph_file)?;
}
if done(&mut step) {
return Ok(());
}
write_marks(dir, &scan_marks(&list_wal(dir)?, 4096))?;
if done(&mut step) {
return Ok(());
}
if attic.exists() {
remove_path(&attic).map_err(io("removing the restored attic entry"))?;
sync_dir(&dir.join(ATTIC_DIR))?;
}
Intent::clear(dir)
}
fn segment_id(path: &DirPath) -> Option<u128> {
let source = open_segment_reader(path, Some(WAL_HEADER_BYTES as u64)).ok()?;
JournalReader::open(source, 0, 1).ok().map(|r| r.graph_id())
}
pub(super) fn attic_state(
dir: &DirPath,
id: &str,
read: &super::super::snapshot::ReadOptions,
) -> Result<(AtticEntry, GraphFile, crate::graph::TraversalGraph), PersistError> {
let (entry, attic_segments) = attic_segments(dir, id)?;
let entry_dir = entry_path(dir, id)?;
let graph_file = read_graph_file(dir)?.file;
let position = graph_file
.lineage
.iter()
.position(|e| e.graph_id == entry.graph_id)
.ok_or_else(|| {
PersistError::store(
"read the attic entry",
StoreFailure::AtticLineageChanged,
"its lineage is not on the store's chain any more",
)
})?;
let mut old = graph_file.clone();
old.lineage = graph_file.lineage[position..].to_vec();
old.graph_id = entry.graph_id;
let own = entry_snapshots(&entry_dir)?.pop();
let (base, path, in_attic) = match own {
Some((seq, path)) => (seq, path, true),
None => {
let path = dir
.join(SNAPSHOTS_DIR)
.join(snapshot_name(entry.base_snapshot));
if !path.join(super::snapshot_dir::MANIFEST_FILE).exists() {
return Err(PersistError::store(
"read the attic entry",
StoreFailure::AtticBaseMissing,
format!(
"the base snapshot at commit {} it continues is missing",
entry.base_snapshot
),
));
}
(entry.base_snapshot, path, false)
}
};
let mut journals: Vec<DirPath> = Vec::new();
if !in_attic {
let prefix_end = prefix_end(&entry);
let main: Vec<(u64, DirPath)> = list_wal(dir)?
.into_iter()
.filter(|(first, _)| *first <= prefix_end)
.collect();
journals.extend(wal_after(&main, base).iter().map(|(_, p)| p.clone()));
journals.extend(attic_segments.iter().map(|(_, p)| p.clone()));
} else {
journals.extend(
wal_after(&attic_segments, base)
.iter()
.map(|(_, p)| p.clone()),
);
}
let mut recovery = Recovery::new()
.snapshot(DirSnapshotReader::open(&path)?)
.options(read.clone())
.target(RecoveryTarget::Latest)
.chain(old.chain());
for path in &journals {
recovery = recovery.journal(open_segment_reader(path, None)?);
}
let recovered = recovery.run()?;
Ok((entry, old, recovered.graph))
}
pub(super) fn attic_fork(
dir: &DirPath,
id: &str,
new_dir: &DirPath,
) -> Result<ForkReport, PersistError> {
super::files::refuse_other_version(dir)?;
let options = super::StoreOptions::new();
let (entry, old, graph) = attic_state(dir, id, &options.read)?;
let options = options
.with_chunk_bytes(old.chunk_bytes)
.with_damage_policy(old.damage_policy);
let branched_at = graph.last_commit_seq();
let parent_id = graph.graph_id();
let graph = graph.with_graph_id(crate::value::uuid::new_v7());
let mut lineage = vec![LineageEntry {
graph_id: graph.graph_id(),
branched_at,
time: crate::graph::undo::now_micros(),
}];
lineage.extend(old.lineage.iter().copied());
super::StoreCore::create_lineage(
new_dir,
&graph,
lineage,
WriteOptions::new().with_parent(parent_id, branched_at),
&options,
)?;
let (_, path) = list_snapshots(new_dir)?.pop().ok_or_else(|| {
PersistError::store(
"fork the attic entry",
StoreFailure::ChangedConcurrently,
"the new store has no snapshot",
)
})?;
Ok(ForkReport {
snapshot: super::snapshot_dir::read_listing(&path)?,
marks_not_carried: attic_marks(dir, &entry, branched_at),
})
}
fn attic_marks(dir: &DirPath, entry: &AtticEntry, up_to: u64) -> Vec<Mark> {
let mut marks = super::marks_up_to(dir, entry.target);
let own = entry_path(dir, &entry.id)
.and_then(|path| entry_wal(&path))
.map(|segments| scan_marks(&segments, crate::value::depth::DEFAULT_MAX_VALUE_DEPTH))
.unwrap_or_default();
for mark in own {
if mark.commit_seq() <= up_to
&& !marks
.iter()
.any(|m| m.name() == mark.name() && m.commit_seq() == mark.commit_seq())
{
marks.push(mark);
}
}
marks.sort_by_key(Mark::commit_seq);
marks
}
pub(super) fn attic_bases_by_name(dir: &DirPath) -> Result<Vec<(String, u64)>, PersistError> {
let Ok(entries) = dir.join(ATTIC_DIR).list() else {
return Ok(Vec::new());
};
let snapshots: Vec<u64> = list_snapshots(dir)?.into_iter().map(|(s, _)| s).collect();
let mut out = Vec::new();
for (name, kind) in entries {
if name.starts_with(REMOVING_PREFIX) || kind != files::EntryKind::Dir {
continue;
}
let Some(first_moved) = name
.rsplit_once('-')
.and_then(|(_, seq)| seq.parse::<u64>().ok())
else {
continue;
};
let own_first = list_wal(&dir.join(ATTIC_DIR).join(&name))
.ok()
.and_then(|wal| wal.first().map(|(first, _)| *first))
.unwrap_or(first_moved);
let target = first_moved.min(own_first).saturating_sub(1);
let base = snapshots
.iter()
.rev()
.find(|s| **s <= target)
.copied()
.unwrap_or(0);
out.push((name, base));
}
Ok(out)
}
pub(super) fn attic_bases(dir: &DirPath) -> Vec<(String, u64)> {
attic_list(dir)
.unwrap_or_default()
.into_iter()
.map(|entry| (entry.id, entry.base_snapshot))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attic_names_and_hex_round_trip() {
assert_eq!(compact_time(0), "19700101T000000Z");
assert_eq!(compact_time(1_760_000_000_000_000), "20251009T085320Z");
assert_eq!(
parse_hex_bytes(&hex_bytes(&[0, 1, 254])),
Some(vec![0, 1, 254])
);
assert_eq!(parse_id(&hex_id(7)), Some(7));
let pairs = read_flat_json(&flat_json(&[("a", "1".into())])).unwrap();
assert_eq!(get(&pairs, "a"), Some("1"));
}
}