#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OplogRecoverFacts {
pub already_healthy: bool,
pub prior_recovery: bool,
pub strategy: Option<String>,
pub entries_recovered: u64,
pub entries_lost: Option<u64>,
pub damaged_byte_start: u64,
pub damaged_byte_end: u64,
pub quarantine_path: Option<String>,
pub sidecar_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OplogRecoverStatus {
HealthyNothingToRecover,
HealthyPriorRecovery,
Salvaged,
}
pub fn plan_oplog_recover_status(
already_healthy: bool,
prior_recovery: bool,
) -> OplogRecoverStatus {
if already_healthy && !prior_recovery {
OplogRecoverStatus::HealthyNothingToRecover
} else if prior_recovery {
OplogRecoverStatus::HealthyPriorRecovery
} else {
OplogRecoverStatus::Salvaged
}
}
pub fn plan_oplog_recover(facts: &OplogRecoverFacts) -> OplogRecoverStatus {
plan_oplog_recover_status(facts.already_healthy, facts.prior_recovery)
}
pub const OPLOG_RECOVER_DEFAULT_STRATEGY: &str = "forward-greedy";
pub fn oplog_recover_headline(status: OplogRecoverStatus, strategy: Option<&str>) -> String {
match status {
OplogRecoverStatus::HealthyNothingToRecover => {
"operation log is healthy; nothing to recover".to_string()
}
OplogRecoverStatus::HealthyPriorRecovery => {
"operation log is healthy; a prior recovery had already salvaged it".to_string()
}
OplogRecoverStatus::Salvaged => {
let strategy = strategy.unwrap_or(OPLOG_RECOVER_DEFAULT_STRATEGY);
format!("salvaged operation log ({strategy} strategy)")
}
}
}
pub fn oplog_recover_headline_from_facts(facts: &OplogRecoverFacts) -> String {
oplog_recover_headline(plan_oplog_recover(facts), facts.strategy.as_deref())
}
pub fn oplog_recover_shows_detail(status: OplogRecoverStatus) -> bool {
!matches!(status, OplogRecoverStatus::HealthyNothingToRecover)
}
pub fn oplog_recover_shows_strategy_field(status: OplogRecoverStatus) -> bool {
matches!(status, OplogRecoverStatus::HealthyPriorRecovery)
}
pub fn oplog_recover_damaged_bytes(start: u64, end: u64) -> u64 {
end.saturating_sub(start)
}
pub fn oplog_recover_entries_lost_display(entries_lost: Option<u64>) -> String {
entries_lost
.map(|count| count.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub fn oplog_recover_damaged_range_display(start: u64, end: u64) -> String {
let damaged_bytes = oplog_recover_damaged_bytes(start, end);
format!("{start}..{end} ({damaged_bytes} bytes)")
}
pub fn oplog_recover_detail_fields(facts: &OplogRecoverFacts) -> Vec<(&'static str, String)> {
let status = plan_oplog_recover(facts);
if !oplog_recover_shows_detail(status) {
return Vec::new();
}
let mut fields = Vec::new();
if oplog_recover_shows_strategy_field(status)
&& let Some(strategy) = facts.strategy.as_ref()
{
fields.push(("Strategy", strategy.clone()));
}
fields.push(("Records recovered", facts.entries_recovered.to_string()));
fields.push((
"Records lost",
oplog_recover_entries_lost_display(facts.entries_lost),
));
fields.push((
"Damaged byte range",
oplog_recover_damaged_range_display(facts.damaged_byte_start, facts.damaged_byte_end),
));
if let Some(quarantine) = &facts.quarantine_path {
fields.push(("Quarantined to", quarantine.clone()));
}
if let Some(sidecar) = &facts.sidecar_path {
fields.push(("Recovery record", sidecar.clone()));
}
fields
}
#[cfg(test)]
mod tests {
use super::*;
fn base_facts() -> OplogRecoverFacts {
OplogRecoverFacts {
already_healthy: false,
prior_recovery: false,
strategy: Some("footer-guided".into()),
entries_recovered: 10,
entries_lost: Some(2),
damaged_byte_start: 100,
damaged_byte_end: 150,
quarantine_path: Some("/tmp/q".into()),
sidecar_path: Some("/tmp/s".into()),
}
}
#[test]
fn status_classification() {
assert_eq!(
plan_oplog_recover_status(true, false),
OplogRecoverStatus::HealthyNothingToRecover
);
assert_eq!(
plan_oplog_recover_status(true, true),
OplogRecoverStatus::HealthyPriorRecovery
);
assert_eq!(
plan_oplog_recover_status(false, false),
OplogRecoverStatus::Salvaged
);
assert!(!oplog_recover_shows_detail(
OplogRecoverStatus::HealthyNothingToRecover
));
assert!(oplog_recover_shows_detail(
OplogRecoverStatus::HealthyPriorRecovery
));
assert!(oplog_recover_shows_detail(OplogRecoverStatus::Salvaged));
assert!(oplog_recover_shows_strategy_field(
OplogRecoverStatus::HealthyPriorRecovery
));
assert!(!oplog_recover_shows_strategy_field(
OplogRecoverStatus::Salvaged
));
}
#[test]
fn headlines_and_field_displays() {
assert_eq!(
oplog_recover_headline(OplogRecoverStatus::HealthyNothingToRecover, None),
"operation log is healthy; nothing to recover"
);
assert!(
oplog_recover_headline(OplogRecoverStatus::Salvaged, None)
.contains(OPLOG_RECOVER_DEFAULT_STRATEGY)
);
assert!(
oplog_recover_headline(OplogRecoverStatus::Salvaged, Some("footer-guided"))
.contains("footer-guided")
);
assert_eq!(oplog_recover_damaged_bytes(100, 150), 50);
assert_eq!(oplog_recover_entries_lost_display(None), "unknown");
assert_eq!(oplog_recover_entries_lost_display(Some(3)), "3");
assert_eq!(oplog_recover_damaged_range_display(1, 5), "1..5 (4 bytes)");
}
#[test]
fn detail_fields_by_status() {
let healthy = OplogRecoverFacts {
already_healthy: true,
prior_recovery: false,
..base_facts()
};
assert!(oplog_recover_detail_fields(&healthy).is_empty());
assert_eq!(
oplog_recover_headline_from_facts(&healthy),
"operation log is healthy; nothing to recover"
);
let prior = OplogRecoverFacts {
already_healthy: true,
prior_recovery: true,
quarantine_path: None,
..base_facts()
};
let fields = oplog_recover_detail_fields(&prior);
assert_eq!(fields[0].0, "Strategy");
assert_eq!(fields[0].1, "footer-guided");
assert!(fields.iter().any(|(k, _)| *k == "Records recovered"));
assert!(fields.iter().any(|(k, _)| *k == "Recovery record"));
assert!(!fields.iter().any(|(k, _)| *k == "Quarantined to"));
let salvaged = base_facts();
let fields = oplog_recover_detail_fields(&salvaged);
assert!(!fields.iter().any(|(k, _)| *k == "Strategy"));
assert!(
fields
.iter()
.any(|(k, v)| *k == "Quarantined to" && v == "/tmp/q")
);
}
}