From a953b1b7cf5d2201002bfc894cfca63329028752 Mon Sep 17 00:00:00 2001 From: overtrue Date: Sun, 21 Jun 2026 12:28:47 +0800 Subject: [PATCH] feat(admin): expose background heal task status --- crates/cli/src/commands/admin/heal.rs | 46 ++++++++++++++++ crates/core/src/admin/cluster.rs | 20 +++++++ crates/s3/src/admin.rs | 79 ++++++++++++++++++++++++++- schemas/output_v2.json | 20 +++++++ 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/admin/heal.rs b/crates/cli/src/commands/admin/heal.rs index a7c6efc..f5222d0 100644 --- a/crates/cli/src/commands/admin/heal.rs +++ b/crates/cli/src/commands/admin/heal.rs @@ -73,6 +73,11 @@ struct HealStatusOutput { healing: bool, bucket: String, object: String, + #[serde(skip_serializing_if = "Option::is_none")] + scan_mode: Option, + scan_cycle: u64, + heal_queue_length: u64, + heal_active_tasks: u64, items_scanned: u64, items_healed: u64, items_failed: u64, @@ -91,6 +96,10 @@ impl From<&HealStatus> for HealStatusOutput { healing: status.healing, bucket: status.bucket.clone(), object: status.object.clone(), + scan_mode: status.scan_mode, + scan_cycle: status.scan_cycle, + heal_queue_length: status.heal_queue_length, + heal_active_tasks: status.heal_active_tasks, items_scanned: status.items_scanned, items_healed: status.items_healed, items_failed: status.items_failed, @@ -107,6 +116,10 @@ fn has_heal_status_details(status: &HealStatus) -> bool { || !status.heal_id.is_empty() || !status.bucket.is_empty() || !status.object.is_empty() + || status.scan_mode.is_some() + || status.scan_cycle > 0 + || status.heal_queue_length > 0 + || status.heal_active_tasks > 0 || status.items_scanned > 0 || status.items_healed > 0 || status.items_failed > 0 @@ -182,6 +195,19 @@ fn print_heal_status(status: &HealStatus, formatter: &Formatter) { )); } + if let Some(scan_mode) = status.scan_mode { + formatter.println(&format!(" Scan Mode: {scan_mode}")); + } + + if status.scan_cycle > 0 { + formatter.println(&format!(" Scan Cycle: {}", status.scan_cycle)); + } + + formatter.println(&format!( + " Tasks: {} queued, {} active", + status.heal_queue_length, status.heal_active_tasks + )); + formatter.println(&format!( " Items: {} scanned, {} healed, {} failed", status.items_scanned, status.items_healed, status.items_failed @@ -326,6 +352,10 @@ mod tests { healing: true, bucket: "test-bucket".to_string(), object: "test/object.txt".to_string(), + scan_mode: Some(HealScanMode::Deep), + scan_cycle: 42, + heal_queue_length: 3, + heal_active_tasks: 1, items_scanned: 1000, items_healed: 50, items_failed: 5, @@ -348,6 +378,9 @@ mod tests { .as_object() .expect("status is object"); assert!(status_value.get("healId").is_some()); + assert!(status_value.get("scanMode").is_some()); + assert!(status_value.get("healQueueLength").is_some()); + assert!(status_value.get("healActiveTasks").is_some()); assert!(status_value.get("itemsScanned").is_some()); } @@ -358,6 +391,10 @@ mod tests { healing: true, bucket: "test-bucket".to_string(), object: "test/object.txt".to_string(), + scan_mode: Some(HealScanMode::Deep), + scan_cycle: 42, + heal_queue_length: 3, + heal_active_tasks: 1, items_scanned: 1000, items_healed: 50, items_failed: 5, @@ -371,6 +408,10 @@ mod tests { assert_eq!(output.heal_id, "heal-123"); assert!(output.healing); assert_eq!(output.bucket, "test-bucket"); + assert_eq!(output.scan_mode, Some(HealScanMode::Deep)); + assert_eq!(output.scan_cycle, 42); + assert_eq!(output.heal_queue_length, 3); + assert_eq!(output.heal_active_tasks, 1); assert_eq!(output.items_scanned, 1000); assert_eq!(output.items_healed, 50); } @@ -388,5 +429,10 @@ mod tests { started: Some("2024-01-01T10:00:00Z".to_string()), ..Default::default() })); + + assert!(has_heal_status_details(&HealStatus { + heal_active_tasks: 1, + ..Default::default() + })); } } diff --git a/crates/core/src/admin/cluster.rs b/crates/core/src/admin/cluster.rs index 96b232b..463c702 100644 --- a/crates/core/src/admin/cluster.rs +++ b/crates/core/src/admin/cluster.rs @@ -578,6 +578,22 @@ pub struct HealStatus { #[serde(default)] pub object: String, + /// Current scan mode reported by background healing + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scan_mode: Option, + + /// Background heal scan cycle + #[serde(default)] + pub scan_cycle: u64, + + /// Number of queued heal tasks + #[serde(default)] + pub heal_queue_length: u64, + + /// Number of active heal tasks + #[serde(default)] + pub heal_active_tasks: u64, + /// Number of items scanned #[serde(default)] pub items_scanned: u64, @@ -929,6 +945,10 @@ mod tests { let status = HealStatus::default(); assert!(status.heal_id.is_empty()); assert!(!status.healing); + assert!(status.scan_mode.is_none()); + assert_eq!(status.scan_cycle, 0); + assert_eq!(status.heal_queue_length, 0); + assert_eq!(status.heal_active_tasks, 0); assert_eq!(status.items_scanned, 0); } diff --git a/crates/s3/src/admin.rs b/crates/s3/src/admin.rs index 74895dc..8255ec7 100644 --- a/crates/s3/src/admin.rs +++ b/crates/s3/src/admin.rs @@ -392,18 +392,43 @@ struct ServiceAccountInfo { struct BackgroundHealStatusResponse { #[serde(default)] bitrot_start_time: Option, + #[serde(default)] + bitrot_start_cycle: u64, + #[serde(default)] + current_scan_mode: Option, + #[serde(default)] + heal_queue_length: u64, + #[serde(default)] + heal_active_tasks: u64, } impl From for HealStatus { fn from(response: BackgroundHealStatusResponse) -> Self { + let scan_mode = background_heal_scan_mode(response.current_scan_mode); + let legacy_healing = scan_mode.is_none() && response.bitrot_start_time.is_some(); Self { - healing: response.bitrot_start_time.is_some(), + healing: matches!(scan_mode, Some(HealScanMode::Deep)) + || response.heal_queue_length > 0 + || response.heal_active_tasks > 0 + || legacy_healing, started: response.bitrot_start_time, + scan_mode, + scan_cycle: response.bitrot_start_cycle, + heal_queue_length: response.heal_queue_length, + heal_active_tasks: response.heal_active_tasks, ..Default::default() } } } +fn background_heal_scan_mode(scan_mode: Option) -> Option { + match scan_mode { + Some(1) => Some(HealScanMode::Normal), + Some(2) => Some(HealScanMode::Deep), + _ => None, + } +} + #[derive(Debug, Serialize)] struct RustfsHealOptions { recursive: bool, @@ -1198,16 +1223,58 @@ mod tests { fn test_background_heal_status_response_maps_to_heal_status() { let status = HealStatus::from(BackgroundHealStatusResponse { bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()), + bitrot_start_cycle: 42, + current_scan_mode: Some(2), + heal_queue_length: 3, + heal_active_tasks: 1, }); assert!(status.healing); assert_eq!(status.started.as_deref(), Some("2026-04-19T10:00:00Z")); + assert_eq!(status.scan_mode, Some(HealScanMode::Deep)); + assert_eq!(status.scan_cycle, 42); + assert_eq!(status.heal_queue_length, 3); + assert_eq!(status.heal_active_tasks, 1); let idle = HealStatus::from(BackgroundHealStatusResponse { bitrot_start_time: None, + bitrot_start_cycle: 0, + current_scan_mode: Some(1), + heal_queue_length: 0, + heal_active_tasks: 0, }); assert!(!idle.healing); + assert_eq!(idle.scan_mode, Some(HealScanMode::Normal)); assert!(idle.started.is_none()); + + let completed = HealStatus::from(BackgroundHealStatusResponse { + bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()), + bitrot_start_cycle: 42, + current_scan_mode: Some(1), + heal_queue_length: 0, + heal_active_tasks: 0, + }); + assert!(!completed.healing); + assert_eq!(completed.scan_mode, Some(HealScanMode::Normal)); + assert_eq!(completed.started.as_deref(), Some("2026-04-19T10:00:00Z")); + + let legacy = HealStatus::from(BackgroundHealStatusResponse { + bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()), + bitrot_start_cycle: 0, + current_scan_mode: None, + heal_queue_length: 0, + heal_active_tasks: 0, + }); + assert!(legacy.healing); + + let active = HealStatus::from(BackgroundHealStatusResponse { + bitrot_start_time: None, + bitrot_start_cycle: 0, + current_scan_mode: None, + heal_queue_length: 0, + heal_active_tasks: 1, + }); + assert!(active.healing); } #[test] @@ -1222,14 +1289,20 @@ mod tests { #[tokio::test] async fn test_heal_status_uses_background_heal_status_endpoint() { - let (endpoint, receiver, handle) = - start_admin_test_server("200 OK", r#"{"bitrotStartTime":"2026-04-19T10:00:00Z"}"#); + let (endpoint, receiver, handle) = start_admin_test_server( + "200 OK", + r#"{"bitrotStartTime":"2026-04-19T10:00:00Z","bitrotStartCycle":42,"currentScanMode":2,"healQueueLength":3,"healActiveTasks":1}"#, + ); let client = admin_client_for_endpoint(&endpoint); let status = client.heal_status().await.expect("heal status request"); assert!(status.healing); assert_eq!(status.started.as_deref(), Some("2026-04-19T10:00:00Z")); + assert_eq!(status.scan_mode, Some(HealScanMode::Deep)); + assert_eq!(status.scan_cycle, 42); + assert_eq!(status.heal_queue_length, 3); + assert_eq!(status.heal_active_tasks, 1); let request = receiver.recv().expect("captured request"); assert_eq!(request.method, "POST"); diff --git a/schemas/output_v2.json b/schemas/output_v2.json index b454e5e..9d74be4 100644 --- a/schemas/output_v2.json +++ b/schemas/output_v2.json @@ -270,6 +270,26 @@ "type": "string", "description": "Object prefix being healed" }, + "scanMode": { + "type": "string", + "enum": [ + "normal", + "deep" + ], + "description": "Current background heal scan mode" + }, + "scanCycle": { + "type": "integer", + "description": "Background heal scan cycle" + }, + "healQueueLength": { + "type": "integer", + "description": "Queued heal tasks" + }, + "healActiveTasks": { + "type": "integer", + "description": "Active heal tasks" + }, "itemsScanned": { "type": "integer", "description": "Items scanned"