use std::collections::VecDeque;
use std::num::NonZeroU32;
use serde::{Deserialize, Serialize};
use crate::boot::{BootKind, BootPhase, Timestamp};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum PhaseResult {
InProgress,
Completed {
elapsed_ms: u64,
},
Failed {
elapsed_ms: u64,
error: String,
},
Cancelled {
elapsed_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PhaseRecord {
pub phase: BootPhase,
pub started_at: Timestamp,
pub result: PhaseResult,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BootKindObservation {
NotYetKnown,
Known {
kind: BootKind,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttemptResult {
InProgress,
Succeeded,
Failed,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BootAttempt {
pub attempt: NonZeroU32,
pub started_at: Timestamp,
pub kind: BootKindObservation,
pub result: AttemptResult,
pub phases: Vec<PhaseRecord>,
}
#[derive(Debug, Default)]
pub struct BootJournal {
attempts: VecDeque<BootAttempt>,
phase_began: Option<tokio::time::Instant>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JournalFile {
version: u32,
attempts: VecDeque<BootAttempt>,
}
const JOURNAL_VERSION: u32 = 1;
impl BootJournal {
pub const CAP: usize = 16;
#[must_use]
pub fn from_bytes(bytes: Option<&[u8]>) -> Self {
let attempts = bytes
.and_then(|b| match serde_json::from_slice::<JournalFile>(b) {
Ok(file) if file.version == JOURNAL_VERSION => Some(file.attempts),
Ok(file) => {
tracing::warn!(
version = file.version,
"boot journal of an unknown version; starting a new one"
);
None
}
Err(err) => {
tracing::warn!(error = %err, "unreadable boot journal; starting a new one");
None
}
})
.unwrap_or_default();
Self {
attempts,
phase_began: None,
}
}
pub fn to_bytes(&self) -> serde_json::Result<Vec<u8>> {
serde_json::to_vec_pretty(&JournalFile {
version: JOURNAL_VERSION,
attempts: self.attempts.clone(),
})
}
#[must_use]
pub fn attempts(&self) -> Vec<BootAttempt> {
self.attempts.iter().cloned().collect()
}
#[must_use]
pub fn latest(&self) -> Option<&BootAttempt> {
self.attempts.back()
}
pub fn begin(&mut self, attempt: NonZeroU32, at: Timestamp) {
if self.attempts.len() == Self::CAP {
self.attempts.pop_front();
}
self.attempts.push_back(BootAttempt {
attempt,
started_at: at,
kind: BootKindObservation::NotYetKnown,
result: AttemptResult::InProgress,
phases: Vec::new(),
});
self.phase_began = None;
}
pub fn entered(&mut self, phase: BootPhase, at: Timestamp) {
let elapsed = self.lap();
let Some(current) = self.attempts.back_mut() else {
return;
};
if let Some(last) = current.phases.last_mut()
&& last.result == PhaseResult::InProgress
{
last.result = PhaseResult::Completed {
elapsed_ms: elapsed,
};
}
current.phases.push(PhaseRecord {
phase,
started_at: at,
result: PhaseResult::InProgress,
});
}
pub fn observed(&mut self, kind: BootKind) {
if let Some(current) = self.attempts.back_mut() {
current.kind = BootKindObservation::Known { kind };
}
}
pub fn succeeded(&mut self) {
self.close(AttemptResult::Succeeded, |elapsed_ms| {
PhaseResult::Completed { elapsed_ms }
});
}
pub fn failed(&mut self, error: &str) {
self.close(AttemptResult::Failed, |elapsed_ms| PhaseResult::Failed {
elapsed_ms,
error: error.to_owned(),
});
}
pub fn cancelled(&mut self) {
self.close(AttemptResult::Cancelled, |elapsed_ms| {
PhaseResult::Cancelled { elapsed_ms }
});
}
fn close(&mut self, result: AttemptResult, last: impl FnOnce(u64) -> PhaseResult) {
let elapsed = self.lap();
let Some(current) = self.attempts.back_mut() else {
return;
};
if current.result != AttemptResult::InProgress {
return;
}
current.result = result;
if let Some(phase) = current.phases.last_mut()
&& phase.result == PhaseResult::InProgress
{
phase.result = last(elapsed);
}
self.phase_began = None;
}
fn lap(&mut self) -> u64 {
let now = tokio::time::Instant::now();
let elapsed = self.phase_began.map_or(0, |began| {
u64::try_from(now.duration_since(began).as_millis()).unwrap_or(u64::MAX)
});
self.phase_began = Some(now);
elapsed
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "seen", rename_all = "snake_case")]
pub enum LastSeen {
Booting {
phase: BootPhase,
},
Running,
Draining,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum RunMarker {
Live {
last_seen: LastSeen,
pid: u32,
},
Released {
at: Timestamp,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "previous", rename_all = "snake_case")]
pub enum PreviousRun {
FirstEver,
CleanStop {
at: Timestamp,
},
Unclean {
last_seen: LastSeen,
},
}
impl PreviousRun {
#[must_use]
pub fn from_marker(marker: Option<&RunMarker>) -> Self {
match marker {
None => Self::FirstEver,
Some(RunMarker::Released { at }) => Self::CleanStop { at: *at },
Some(RunMarker::Live { last_seen, .. }) => Self::Unclean {
last_seen: *last_seen,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentityRecord {
pub cluster_name: String,
pub node_name: String,
pub recorded_at: Timestamp,
}
#[cfg(test)]
mod tests {
use super::*;
fn t(secs: u64) -> Timestamp {
Timestamp::parse("2026-09-22T00:00:00Z")
.expect("literal")
.after(std::time::Duration::from_secs(secs))
}
#[tokio::test(start_paused = true)]
async fn phases_are_timed_on_the_monotonic_clock_and_closed_by_the_outcome() {
let mut journal = BootJournal::default();
journal.begin(NonZeroU32::MIN, t(0));
journal.entered(BootPhase::ResolveConfig, t(0));
tokio::time::advance(std::time::Duration::from_millis(250)).await;
journal.entered(BootPhase::ReadBootConfig, t(0));
journal.observed(BootKind::Resume);
tokio::time::advance(std::time::Duration::from_millis(40)).await;
journal.failed("nope");
let latest = journal.latest().expect("an attempt");
assert_eq!(latest.result, AttemptResult::Failed);
assert_eq!(
latest.kind,
BootKindObservation::Known {
kind: BootKind::Resume
}
);
assert_eq!(
latest
.phases
.iter()
.map(|p| p.result.clone())
.collect::<Vec<_>>(),
[
PhaseResult::Completed { elapsed_ms: 250 },
PhaseResult::Failed {
elapsed_ms: 40,
error: "nope".into()
},
]
);
}
#[test]
fn the_journal_keeps_the_most_recent_attempts_and_survives_a_round_trip() {
let mut journal = BootJournal::default();
for n in 1..=20u32 {
journal.begin(NonZeroU32::new(n).expect("non-zero"), t(u64::from(n)));
journal.cancelled();
}
let attempts = journal.attempts();
assert_eq!(attempts.len(), BootJournal::CAP);
assert_eq!(attempts[0].attempt.get(), 5);
let bytes = journal.to_bytes().expect("serialize");
assert_eq!(BootJournal::from_bytes(Some(&bytes)).attempts(), attempts);
assert!(
BootJournal::from_bytes(Some(b"not json"))
.attempts()
.is_empty()
);
assert!(BootJournal::from_bytes(None).attempts().is_empty());
}
#[test]
fn the_previous_run_is_read_from_the_marker_it_left() {
assert_eq!(PreviousRun::from_marker(None), PreviousRun::FirstEver);
assert_eq!(
PreviousRun::from_marker(Some(&RunMarker::Released { at: t(1) })),
PreviousRun::CleanStop { at: t(1) }
);
let live = RunMarker::Live {
last_seen: LastSeen::Booting {
phase: BootPhase::OpenStore,
},
pid: 7,
};
assert_eq!(
PreviousRun::from_marker(Some(&live)),
PreviousRun::Unclean {
last_seen: LastSeen::Booting {
phase: BootPhase::OpenStore
}
}
);
assert_eq!(
serde_json::to_value(PreviousRun::Unclean {
last_seen: LastSeen::Running
})
.expect("serialize"),
serde_json::json!({"previous": "unclean", "last_seen": {"seen": "running"}})
);
}
}