use std::path::Path;
pub const JOURNAL_PATH: &str = "/serialdat/grow-journal";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub epoch: u64,
pub target_sectors: u64,
pub member: Option<u32>,
pub step: String,
pub at: u64,
}
impl Record {
pub fn parse(line: &str) -> Option<Record> {
let mut epoch = None;
let mut target = None;
let mut member: Option<Option<u32>> = None;
let mut step = None;
let mut at = None;
for tok in line.split_whitespace() {
let (k, v) = tok.split_once('=')?;
match k {
"epoch" => epoch = Some(v.parse().ok()?),
"target_sectors" => target = Some(v.parse().ok()?),
"member" => member = Some(if v == "-" { None } else { Some(v.parse().ok()?) }),
"step" => step = Some(v.to_owned()),
"at" => at = Some(v.parse().ok()?),
_ => return None,
}
}
Some(Record { epoch: epoch?, target_sectors: target?, member: member?, step: step?, at: at? })
}
pub fn label(&self) -> String {
match self.member {
Some(m) => format!("{}:{m}", self.step),
None => self.step.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Idle,
InProgress,
Done,
Partial,
Aborted,
Failed,
}
impl Phase {
pub fn as_str(self) -> &'static str {
match self {
Phase::Idle => "idle",
Phase::InProgress => "in_progress",
Phase::Done => "done",
Phase::Partial => "partial",
Phase::Aborted => "aborted",
Phase::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Summary {
pub path: String,
pub records: usize,
pub torn: usize,
pub phase: Phase,
pub epoch: Option<u64>,
pub target_sectors: Option<u64>,
pub steps: Vec<String>,
pub partial_members: Vec<u32>,
}
impl Summary {
pub fn of(text: &str, path: &str) -> Summary {
let mut records = Vec::new();
let mut torn = 0;
for line in text.lines().filter(|l| !l.trim().is_empty()) {
match Record::parse(line) {
Some(r) => records.push(r),
None => torn += 1,
}
}
let trailing_partial: Vec<&Record> = {
let mut v: Vec<&Record> = records.iter().rev().take_while(|r| r.step == "PARTIAL").collect();
v.reverse();
v
};
let epoch = records.iter().filter(|r| r.step != "PARTIAL").map(|r| r.epoch).max();
let mine: Vec<&Record> = records.iter().filter(|r| Some(r.epoch) == epoch && r.step != "PARTIAL").collect();
let target_sectors = mine.last().map(|r| r.target_sectors);
let steps: Vec<String> = mine.iter().map(|r| r.label()).collect();
let phase = if !trailing_partial.is_empty() {
Phase::Partial
} else if epoch.is_none() {
Phase::Idle
} else if mine.iter().any(|r| r.step == "ABORT") {
Phase::Aborted
} else if mine.iter().any(|r| r.step == "G6") {
Phase::Done
} else if mine.iter().any(|r| r.step == "FAIL") {
Phase::Failed
} else {
Phase::InProgress
};
Summary {
path: path.to_owned(),
records: records.len(),
torn,
phase,
epoch,
target_sectors,
steps,
partial_members: trailing_partial.iter().filter_map(|r| r.member).collect(),
}
}
pub fn read(path: &Path) -> Option<Summary> {
let text = std::fs::read_to_string(path).ok()?;
Some(Summary::of(&text, &path.display().to_string()))
}
pub fn measure() -> Option<Summary> {
Summary::read(Path::new(JOURNAL_PATH))
}
pub fn detail(&self) -> String {
let gib = |s: u64| format!("{:.1} GiB", (s as f64) * 512.0 / (1u64 << 30) as f64);
let torn = if self.torn > 0 { format!(" ({} torn line(s) skipped)", self.torn) } else { String::new() };
match self.phase {
Phase::Idle => format!("no grow has run on this box (journal empty{torn})"),
Phase::Partial => format!(
"PARTIAL RESIZE: {} member(s) [{}] are larger than the set and the rest are not — NOT grown; enlarge the remaining members to the same size and restart{torn}",
self.partial_members.len(),
self.partial_members.iter().map(|m| m.to_string()).collect::<Vec<_>>().join(", ")
),
Phase::InProgress => format!(
"epoch {} IN PROGRESS toward {} per member — recorded so far: {}; the next boot (or `gunnar control grow resume`) resumes it{torn}",
self.epoch.unwrap_or(0),
self.target_sectors.map(gib).unwrap_or_default(),
self.steps.join(" ")
),
Phase::Done => format!(
"epoch {} done: the set grew to {} per member ({}){torn}",
self.epoch.unwrap_or(0),
self.target_sectors.map(gib).unwrap_or_default(),
self.steps.join(" ")
),
Phase::Aborted => format!(
"epoch {} ABORTED at runtime with nothing touched ({}); the set is as it was{torn}",
self.epoch.unwrap_or(0),
self.steps.join(" ")
),
Phase::Failed => format!(
"epoch {} FAILED at runtime after a member was touched ({}); /data serves at the size it has and the next boot (or `gunnar control grow resume`) finishes the epoch{torn}",
self.epoch.unwrap_or(0),
self.steps.join(" ")
),
}
}
pub fn to_wire(&self) -> gunnar_proto::GrowState {
gunnar_proto::GrowState {
engine_present: true,
phase: self.phase.as_str().to_owned(),
epoch: self.epoch.unwrap_or(0),
target_sectors: self.target_sectors.unwrap_or(0),
steps: self.steps.clone(),
partial_members: self.partial_members.clone(),
detail: self.detail(),
journal_path: self.path.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_reader_parses_the_writers_exact_line_and_skips_a_torn_tail() {
let r = Record::parse("epoch=3 target_sectors=268433408 member=1 step=G2 at=1789394521").unwrap();
assert_eq!(r, Record { epoch: 3, target_sectors: 268_433_408, member: Some(1), step: "G2".into(), at: 1_789_394_521 });
assert_eq!(r.label(), "G2:1");
let g4 = Record::parse("epoch=3 target_sectors=268433408 member=- step=G4 at=1789394521").unwrap();
assert_eq!((g4.member, g4.label()), (None, "G4".into()));
assert!(Record::parse("epoch=3 target_sec").is_none());
assert!(Record::parse("epoch=3 target_sectors=1 member=- step=G1 at=1 x=y").is_none());
}
#[test]
fn the_summary_names_idle_in_progress_done_and_partial() {
let idle = Summary::of("", "j");
assert_eq!((idle.phase, idle.epoch, idle.records), (Phase::Idle, None, 0));
assert!(idle.detail().contains("no grow has run"));
let open = "epoch=1 target_sectors=100 member=- step=G1 at=1\nepoch=1 target_sectors=100 member=0 step=G2 at=2\nepoch=1 target_sectors=100 member=1 step=G2 at=3\nepoch=1 targ";
let s = Summary::of(open, "j");
assert_eq!((s.phase, s.epoch, s.target_sectors, s.torn), (Phase::InProgress, Some(1), Some(100), 1));
assert_eq!(s.steps, vec!["G1", "G2:0", "G2:1"]);
assert!(s.detail().contains("IN PROGRESS") && s.detail().contains("G2:1") && s.detail().contains("1 torn"), "{}", s.detail());
let done = format!("{}\nepoch=1 target_sectors=100 member=- step=G6 at=9\n", open.trim_end_matches("epoch=1 targ"));
let s = Summary::of(&done, "j");
assert_eq!(s.phase, Phase::Done);
assert!(s.steps.last().map(String::as_str) == Some("G6"));
let wire = s.to_wire();
assert!(wire.engine_present && wire.phase == "done" && wire.epoch == 1 && wire.target_sectors == 100 && wire.steps.len() == 4);
let partial = format!("{done}epoch=1 target_sectors=200 member=0 step=PARTIAL at=10\nepoch=1 target_sectors=200 member=2 step=PARTIAL at=10\n");
let s = Summary::of(&partial, "j");
assert_eq!((s.phase, s.partial_members.clone()), (Phase::Partial, vec![0, 2]));
assert!(s.detail().contains("PARTIAL RESIZE: 2 member(s) [0, 2]"), "{}", s.detail());
assert_eq!(s.epoch, Some(1), "a partial verdict opens no epoch");
assert!(Summary::read(Path::new("/nonexistent/grow-journal")).is_none());
}
#[test]
fn the_summary_names_the_runtime_phases_aborted_and_failed() {
let prelude = "epoch=2 target_sectors=0 member=- step=DRAIN at=1\nepoch=2 target_sectors=0 member=- step=UNMOUNT at=2\nepoch=2 target_sectors=0 member=- step=STOP at=3\nepoch=2 target_sectors=0 member=- step=WAIT at=4\n";
let s = Summary::of(prelude, "j");
assert_eq!((s.phase, s.epoch), (Phase::InProgress, Some(2)));
assert_eq!(s.steps, vec!["DRAIN", "UNMOUNT", "STOP", "WAIT"]);
assert!(s.detail().contains("grow resume"), "{}", s.detail());
let aborted = format!("{prelude}epoch=2 target_sectors=0 member=- step=ABORT at=5\n");
let s = Summary::of(&aborted, "j");
assert_eq!(s.phase, Phase::Aborted);
assert_eq!(s.to_wire().phase, "aborted");
assert!(s.detail().contains("ABORTED") && s.detail().contains("nothing touched"), "{}", s.detail());
let failed = format!("{prelude}epoch=2 target_sectors=0 member=- step=RESCAN at=5\nepoch=2 target_sectors=500 member=- step=G1 at=6\nepoch=2 target_sectors=500 member=0 step=G2 at=7\nepoch=2 target_sectors=500 member=- step=FAIL at=8\nepoch=2 target_sectors=500 member=- step=REOPEN at=9\n");
let s = Summary::of(&failed, "j");
assert_eq!((s.phase, s.target_sectors), (Phase::Failed, Some(500)));
assert!(s.detail().contains("FAILED") && s.detail().contains("serves at the size it has"), "{}", s.detail());
let done = format!("{prelude}epoch=2 target_sectors=0 member=- step=RESCAN at=5\nepoch=2 target_sectors=500 member=- step=G1 at=6\nepoch=2 target_sectors=500 member=- step=G6 at=7\nepoch=2 target_sectors=500 member=- step=REOPEN at=8\n");
let s = Summary::of(&done, "j");
assert_eq!(s.phase, Phase::Done, "G6 closes a runtime epoch like any other");
}
#[test]
fn an_old_lines_failed_epoch_reads_done_once_the_next_growth_closes_its_own() {
let d5 = "epoch=1 target_sectors=31457280 member=- step=DRAIN at=1\nepoch=1 target_sectors=31457280 member=- step=G6 at=2\nepoch=1 target_sectors=31457280 member=- step=REOPEN at=3\n\
epoch=2 target_sectors=130023424 member=- step=DRAIN at=4\nepoch=2 target_sectors=130023424 member=- step=UNMOUNT at=5\nepoch=2 target_sectors=130023424 member=- step=STOP at=6\n\
epoch=2 target_sectors=130023424 member=- step=WAIT at=7\nepoch=2 target_sectors=130023424 member=- step=RESCAN at=8\nepoch=2 target_sectors=130023424 member=- step=FAIL at=9\n\
epoch=2 target_sectors=130023424 member=- step=REOPEN at=10\n";
assert_eq!((Summary::of(d5, "j").phase, Summary::of(d5, "j").epoch), (Phase::Failed, Some(2)));
let recovered = format!(
"{d5}epoch=3 target_sectors=165675008 member=- step=DRAIN at=11\nepoch=3 target_sectors=165675008 member=- step=UNMOUNT at=12\nepoch=3 target_sectors=165675008 member=- step=STOP at=13\n\
epoch=3 target_sectors=165675008 member=- step=WAIT at=14\nepoch=3 target_sectors=165675008 member=- step=RESCAN at=15\nepoch=3 target_sectors=165675008 member=- step=G1 at=16\n\
epoch=3 target_sectors=165675008 member=0 step=G2 at=17\nepoch=3 target_sectors=165675008 member=- step=G6 at=18\nepoch=3 target_sectors=165675008 member=- step=REOPEN at=19\n"
);
let s = Summary::of(&recovered, "j");
assert_eq!((s.phase, s.epoch), (Phase::Done, Some(3)), "{}", s.detail());
assert!(!s.detail().contains("FAILED"), "{}", s.detail());
}
}