diff --git a/crates/heal/src/heal/task.rs b/crates/heal/src/heal/task.rs index fcdbd03ed..780ccf022 100644 --- a/crates/heal/src/heal/task.rs +++ b/crates/heal/src/heal/task.rs @@ -1033,7 +1033,8 @@ impl HealTask { bucket, object, object_size = object_size, - drives_healed = result.after.drives.len(), + drives_healed = result.drives_healed(), + drives_total = result.drives_reported(), result = "ok", "Heal object repaired" ); @@ -1329,7 +1330,8 @@ impl HealTask { subsystem = LOG_SUBSYSTEM_TASK, task_id = %self.id, bucket, - drives_healed = result.after.drives.len(), + drives_healed = result.drives_healed(), + drives_total = result.drives_reported(), recursive = self.options.recursive, result = "ok", "Heal bucket completed" @@ -1773,7 +1775,8 @@ impl HealTask { task_id = %self.id, bucket, object, - drives_healed = result.after.drives.len(), + drives_healed = result.drives_healed(), + drives_total = result.drives_reported(), result = "ok", "Heal metadata repaired" ); @@ -1904,7 +1907,8 @@ impl HealTask { meta_path, bucket, object = %object, - drives_healed = result.after.drives.len(), + drives_healed = result.drives_healed(), + drives_total = result.drives_reported(), result = "ok", "Heal MRF repaired" ); @@ -2068,7 +2072,8 @@ impl HealTask { bucket, object, object_size, - drives_healed = result.after.drives.len(), + drives_healed = result.drives_healed(), + drives_total = result.drives_reported(), result = "ok", "Heal EC decode repaired" ); @@ -2198,7 +2203,8 @@ impl HealTask { subsystem = LOG_SUBSYSTEM_TASK, task_id = %self.id, set_disk_id, - drives_healed = result.after.drives.len(), + drives_healed = result.drives_healed(), + drives_total = result.drives_reported(), result = "format_ok", "Heal erasure set format repaired" ); diff --git a/crates/madmin/src/heal_commands.rs b/crates/madmin/src/heal_commands.rs index 24562c12b..faa2763b1 100644 --- a/crates/madmin/src/heal_commands.rs +++ b/crates/madmin/src/heal_commands.rs @@ -29,6 +29,11 @@ pub struct Infos { pub drives: Vec, } +/// String form of `DriveState::Ok` as recorded in `HealDriveInfo::state` +/// (this crate stores drive states as strings and does not depend on the +/// enum's crate). +const DRIVE_STATE_OK: &str = "ok"; + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct HealResultItem { #[serde(rename = "resultId")] @@ -58,3 +63,81 @@ pub struct HealResultItem { #[serde(rename = "objectSize")] pub object_size: usize, } + +impl HealResultItem { + /// Number of drives this heal repaired: pairwise `before`/`after` state + /// transitions to ok (issue #5863). `None` when the result carries no + /// aligned drive data (e.g. remote bucket results) — not the same as zero. + pub fn drives_healed(&self) -> Option { + if self.after.drives.is_empty() || self.before.drives.len() != self.after.drives.len() { + return None; + } + Some( + self.before + .drives + .iter() + .zip(&self.after.drives) + .filter(|(before, after)| before.state != after.state && after.state == DRIVE_STATE_OK) + .count(), + ) + } + + /// Drives consulted, or `None` when the result has no drive entries. + pub fn drives_reported(&self) -> Option { + if self.after.drives.is_empty() { + None + } else { + Some(self.after.drives.len()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn drive(state: &str) -> HealDriveInfo { + HealDriveInfo { + uuid: String::new(), + endpoint: String::new(), + state: state.to_string(), + } + } + + #[test] + fn drives_healed_counts_transitions_to_ok_not_consulted_drives() { + let mut item = HealResultItem::default(); + item.before.drives = vec![drive("ok"), drive("missing"), drive("corrupt"), drive("offline")]; + item.after.drives = vec![drive("ok"), drive("ok"), drive("ok"), drive("offline")]; + // 4 drives consulted, 2 repaired (missing->ok, corrupt->ok); the + // already-ok drive and the still-offline drive are not repairs. + assert_eq!(item.drives_healed(), Some(2)); + assert_eq!(item.drives_reported(), Some(4)); + + let mut noop = HealResultItem::default(); + noop.before.drives = vec![drive("ok"); 12]; + noop.after.drives = vec![drive("ok"); 12]; + assert_eq!(noop.drives_healed(), Some(0)); + } + + #[test] + fn drives_healed_reports_unknown_not_zero_without_drive_data() { + // Empty successful remote result (RemotePeerS3Client::heal_bucket + // default) is "unknown", never a definitive zero. + let remote = HealResultItem::default(); + assert_eq!(remote.drives_healed(), None); + assert_eq!(remote.drives_reported(), None); + + // A local missing -> ok result keeps its real count. + let mut local = HealResultItem::default(); + local.before.drives = vec![drive("ok"), drive("missing")]; + local.after.drives = vec![drive("ok"), drive("ok")]; + assert_eq!(local.drives_healed(), Some(1)); + + // Misaligned arrays cannot be paired: also unknown. + let mut misaligned = HealResultItem::default(); + misaligned.before.drives = vec![drive("missing")]; + misaligned.after.drives = vec![drive("ok"), drive("ok")]; + assert_eq!(misaligned.drives_healed(), None); + } +}