use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::scratch::{self, ScratchDir};
use crate::witness::{canonical, AssumedDurable, DurabilityWitness, SyncKind, SyncRecord};
use crate::{Error, Result};
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Tier {
ProcessCrash,
PowerCut,
}
impl Tier {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Tier::ProcessCrash => "process-crash",
Tier::PowerCut => "power-cut",
}
}
#[must_use]
pub const fn models(self) -> &'static str {
match self {
Tier::ProcessCrash => {
"a process or OS crash: the page cache still reaches the filesystem, \
so every byte the application wrote survives — including bytes no \
fsync ever certified"
}
Tier::PowerCut => {
"a real power cut: the page cache is lost, so only bytes that a \
successful fsync certified before the ack survive"
}
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum Warrant {
Synced(SyncRecord),
OnFilesystem,
Assumed {
reason: String,
},
}
impl Warrant {
#[must_use]
pub fn is_measured(&self) -> bool {
matches!(self, Warrant::Synced(_))
}
#[must_use]
pub fn describe(&self) -> String {
match self {
Warrant::Synced(r) => format!(
"fsync({}) at barrier {}{}",
r.kind.as_str(),
r.at,
r.durable_len
.map(|l| format!(", durable length {l}"))
.unwrap_or_default()
),
Warrant::OnFilesystem => "on the filesystem at the ack".to_string(),
Warrant::Assumed { reason } => format!("ASSUMED durable — {reason}"),
}
}
}
#[derive(Clone, Debug)]
pub struct CapturedFile {
pub path: PathBuf,
pub bytes: u64,
pub warrant: Warrant,
pub truncated_from: Option<u64>,
}
#[derive(Debug)]
pub struct CrashState {
dir: ScratchDir,
tier: Tier,
manifest: Vec<CapturedFile>,
notes: Vec<String>,
}
impl CrashState {
#[must_use]
pub fn dir(&self) -> &Path {
self.dir.path()
}
#[must_use]
pub fn tier(&self) -> Tier {
self.tier
}
#[must_use]
pub fn manifest(&self) -> &[CapturedFile] {
&self.manifest
}
#[must_use]
pub fn notes(&self) -> &[String] {
&self.notes
}
#[must_use]
pub fn used_assumption(&self) -> bool {
self.manifest
.iter()
.any(|f| matches!(f.warrant, Warrant::Assumed { .. }))
}
#[must_use]
pub fn manifest_text(&self) -> String {
let mut out = format!(
"surviving files at tier {} ({} file(s)):\n",
self.tier.as_str(),
self.manifest.len()
);
if self.manifest.is_empty() {
out.push_str(" (none — nothing at all was certified durable)\n");
}
for f in &self.manifest {
out.push_str(&format!(
" {:<28} {:>10} B {}{}\n",
f.path.display(),
f.bytes,
f.warrant.describe(),
f.truncated_from
.map(|l| format!(" [truncated from {l} B live]"))
.unwrap_or_default()
));
}
out
}
pub fn capture<W: DurabilityWitness>(
store: impl AsRef<Path>,
tier: Tier,
witness: &W,
) -> Result<Self> {
let store = store.as_ref();
if !store.is_dir() {
return Err(Error::StoreMissing(store.to_path_buf()));
}
let dir = ScratchDir::new(&format!("crash-{}", tier.as_str()))
.map_err(|e| Error::io(Some(store.to_path_buf()), e))?;
let (files, symlinks) =
scratch::walk_files(store).map_err(|e| Error::io(Some(store.to_path_buf()), e))?;
let mut notes: Vec<String> = symlinks
.iter()
.map(|p| {
format!(
"skipped `{}`: powercut copies regular files only, so a store that \
keeps state behind a symlink is not fully captured",
p.display()
)
})
.collect();
let index = SyncIndex::build(witness);
let mut manifest = Vec::new();
for relative in &files {
let live = store.join(relative);
let live_len = std::fs::metadata(&live).map(|m| m.len()).unwrap_or(0);
let key = canonical(&live);
let (warrant, source) = match tier {
Tier::ProcessCrash => (Warrant::OnFilesystem, Source::everything()),
Tier::PowerCut => match index.plan_for(&key) {
Some(plan) => plan,
None => continue,
},
};
if source.from.is_none() && !source.exact {
notes.push(format!(
"`{}` was certified durable but no content snapshot was taken, so tier 2 \
used its LIVE bytes truncated to the durable length. That is optimistic: \
an in-place overwrite made after the sync is credited as durable when it \
is not. Route the sync through `SyncJournal` to get an exact capture.",
relative.display()
));
}
let from = source.from.as_deref().unwrap_or(live.as_path());
let dst = dir.path().join(relative);
let bytes = scratch::copy_file(from, &dst, source.limit)
.map_err(|e| Error::io(Some(from.to_path_buf()), e))?;
manifest.push(CapturedFile {
path: relative.clone(),
bytes,
warrant,
truncated_from: (bytes < live_len).then_some(live_len),
});
}
if tier == Tier::PowerCut {
notes.extend(index.integrity_notes(store, &files));
}
Ok(Self {
dir,
tier,
manifest,
notes,
})
}
}
struct Source {
from: Option<PathBuf>,
limit: Option<u64>,
exact: bool,
}
impl Source {
fn live(limit: Option<u64>) -> Self {
Self {
from: None,
limit,
exact: false,
}
}
fn everything() -> Self {
Self {
from: None,
limit: None,
exact: true,
}
}
}
struct SyncIndex {
synced: BTreeMap<PathBuf, SyncRecord>,
synced_dirs: Vec<PathBuf>,
assumed: BTreeMap<PathBuf, AssumedDurable>,
}
impl SyncIndex {
fn build<W: DurabilityWitness>(witness: &W) -> Self {
let mut synced: BTreeMap<PathBuf, SyncRecord> = BTreeMap::new();
let mut synced_dirs = Vec::new();
for record in witness.synced() {
if record.kind == SyncKind::Directory {
synced_dirs.push(record.path.clone());
continue;
}
synced
.entry(record.path.clone())
.and_modify(|existing| {
if record.at > existing.at {
*existing = record.clone();
}
})
.or_insert(record);
}
let assumed = witness
.assumed()
.into_iter()
.map(|a| (a.path.clone(), a))
.collect();
Self {
synced,
synced_dirs,
assumed,
}
}
fn plan_for(&self, key: &Path) -> Option<(Warrant, Source)> {
if let Some(record) = self.synced.get(key) {
let source = match record.snapshot.as_ref().filter(|p| p.is_file()) {
Some(snapshot) => Source {
from: Some(snapshot.clone()),
limit: None,
exact: true,
},
None => Source::live(record.durable_len),
};
return Some((Warrant::Synced(record.clone()), source));
}
self.assumed.get(key).map(|a| {
(
Warrant::Assumed {
reason: a.reason.clone(),
},
Source {
from: None,
limit: None,
exact: true,
},
)
})
}
fn integrity_notes(&self, store: &Path, present: &[PathBuf]) -> Vec<String> {
let live: Vec<PathBuf> = present.iter().map(|p| canonical(&store.join(p))).collect();
let mut notes = Vec::new();
for (path, record) in &self.synced {
if !live.contains(path) {
notes.push(format!(
"`{}` was fsynced at barrier {} but is GONE from the store at capture \
time. Something unlinked or replaced a file that had already been \
certified durable — this is the signature of a checkpoint, a \
compaction, or a second connection closing.",
display_name(path),
record.at
));
continue;
}
let live_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if let Some(durable_len) = record.durable_len {
if live_len < durable_len {
notes.push(format!(
"`{}` was fsynced at length {durable_len} but is only {live_len} B now. \
Durable bytes were REMOVED after being certified; if the data they \
held moved into another file, that file needs its own fsync or the \
data is no longer durable anywhere.",
display_name(path)
));
}
}
if let Some(snapshot) = record.snapshot.as_ref() {
let certified = std::fs::read(snapshot).ok();
let now = std::fs::read(path).ok();
if certified.is_some() && now.is_some() && certified != now {
notes.push(format!(
"`{}` was REWRITTEN IN PLACE after being certified durable, and the \
rewrite was never synced. Tier 2 therefore captured the bytes as of \
the last fsync, not the bytes on disk now. A checkpoint, a compaction \
or a second handle closing does exactly this.",
display_name(path)
));
}
}
if let Some(parent) = path.parent() {
if !self.synced_dirs.iter().any(|d| d == parent) {
notes.push(format!(
"`{}` was fsynced but its parent directory never was. On Unix a \
newly created file's NAME is only durable after the directory is \
fsynced, so this file may be absent after a real power cut even \
though its bytes were flushed.",
display_name(path)
));
}
}
}
notes
}
}
fn display_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::witness::SyncJournal;
#[test]
fn power_cut_keeps_only_the_synced_prefix() {
let store = ScratchDir::new("prefix").unwrap();
let path = store.path().join("log");
std::fs::write(&path, b"durable").unwrap();
let journal = SyncJournal::complete();
journal.sync_path(&path).unwrap();
std::fs::write(&path, b"durable+volatile").unwrap();
let crash = CrashState::capture(store.path(), Tier::PowerCut, &journal).unwrap();
assert_eq!(std::fs::read(crash.dir().join("log")).unwrap(), b"durable");
assert_eq!(crash.manifest()[0].truncated_from, Some(16));
}
#[test]
fn process_crash_keeps_everything() {
let store = ScratchDir::new("everything").unwrap();
std::fs::write(store.path().join("never-synced"), b"x").unwrap();
let journal = SyncJournal::complete();
let crash = CrashState::capture(store.path(), Tier::ProcessCrash, &journal).unwrap();
assert_eq!(crash.manifest().len(), 1);
}
#[test]
fn an_unsynced_in_place_rewrite_is_not_credited() {
let store = ScratchDir::new("rewrite").unwrap();
let path = store.path().join("db");
std::fs::write(&path, b"AAAA").unwrap();
let journal = SyncJournal::complete();
journal.sync_path(&path).unwrap();
std::fs::write(&path, b"BBBB").unwrap();
let crash = CrashState::capture(store.path(), Tier::PowerCut, &journal).unwrap();
assert_eq!(
std::fs::read(crash.dir().join("db")).unwrap(),
b"AAAA",
"tier 2 must capture the bytes the fsync certified, not the bytes on disk now"
);
assert!(crash
.notes()
.iter()
.any(|n| n.contains("REWRITTEN IN PLACE")));
}
#[test]
fn a_vanished_durable_file_is_reported() {
let store = ScratchDir::new("vanished").unwrap();
let path = store.path().join("wal");
std::fs::write(&path, b"acked").unwrap();
let journal = SyncJournal::complete();
journal.sync_path(&path).unwrap();
std::fs::remove_file(&path).unwrap();
let crash = CrashState::capture(store.path(), Tier::PowerCut, &journal).unwrap();
assert!(crash.notes().iter().any(|n| n.contains("GONE")));
}
}