Skip to content

Commit a953b1b

Browse files
committed
feat(admin): expose background heal task status
1 parent 9036ab1 commit a953b1b

4 files changed

Lines changed: 162 additions & 3 deletions

File tree

crates/cli/src/commands/admin/heal.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ struct HealStatusOutput {
7373
healing: bool,
7474
bucket: String,
7575
object: String,
76+
#[serde(skip_serializing_if = "Option::is_none")]
77+
scan_mode: Option<HealScanMode>,
78+
scan_cycle: u64,
79+
heal_queue_length: u64,
80+
heal_active_tasks: u64,
7681
items_scanned: u64,
7782
items_healed: u64,
7883
items_failed: u64,
@@ -91,6 +96,10 @@ impl From<&HealStatus> for HealStatusOutput {
9196
healing: status.healing,
9297
bucket: status.bucket.clone(),
9398
object: status.object.clone(),
99+
scan_mode: status.scan_mode,
100+
scan_cycle: status.scan_cycle,
101+
heal_queue_length: status.heal_queue_length,
102+
heal_active_tasks: status.heal_active_tasks,
94103
items_scanned: status.items_scanned,
95104
items_healed: status.items_healed,
96105
items_failed: status.items_failed,
@@ -107,6 +116,10 @@ fn has_heal_status_details(status: &HealStatus) -> bool {
107116
|| !status.heal_id.is_empty()
108117
|| !status.bucket.is_empty()
109118
|| !status.object.is_empty()
119+
|| status.scan_mode.is_some()
120+
|| status.scan_cycle > 0
121+
|| status.heal_queue_length > 0
122+
|| status.heal_active_tasks > 0
110123
|| status.items_scanned > 0
111124
|| status.items_healed > 0
112125
|| status.items_failed > 0
@@ -182,6 +195,19 @@ fn print_heal_status(status: &HealStatus, formatter: &Formatter) {
182195
));
183196
}
184197

198+
if let Some(scan_mode) = status.scan_mode {
199+
formatter.println(&format!(" Scan Mode: {scan_mode}"));
200+
}
201+
202+
if status.scan_cycle > 0 {
203+
formatter.println(&format!(" Scan Cycle: {}", status.scan_cycle));
204+
}
205+
206+
formatter.println(&format!(
207+
" Tasks: {} queued, {} active",
208+
status.heal_queue_length, status.heal_active_tasks
209+
));
210+
185211
formatter.println(&format!(
186212
" Items: {} scanned, {} healed, {} failed",
187213
status.items_scanned, status.items_healed, status.items_failed
@@ -326,6 +352,10 @@ mod tests {
326352
healing: true,
327353
bucket: "test-bucket".to_string(),
328354
object: "test/object.txt".to_string(),
355+
scan_mode: Some(HealScanMode::Deep),
356+
scan_cycle: 42,
357+
heal_queue_length: 3,
358+
heal_active_tasks: 1,
329359
items_scanned: 1000,
330360
items_healed: 50,
331361
items_failed: 5,
@@ -348,6 +378,9 @@ mod tests {
348378
.as_object()
349379
.expect("status is object");
350380
assert!(status_value.get("healId").is_some());
381+
assert!(status_value.get("scanMode").is_some());
382+
assert!(status_value.get("healQueueLength").is_some());
383+
assert!(status_value.get("healActiveTasks").is_some());
351384
assert!(status_value.get("itemsScanned").is_some());
352385
}
353386

@@ -358,6 +391,10 @@ mod tests {
358391
healing: true,
359392
bucket: "test-bucket".to_string(),
360393
object: "test/object.txt".to_string(),
394+
scan_mode: Some(HealScanMode::Deep),
395+
scan_cycle: 42,
396+
heal_queue_length: 3,
397+
heal_active_tasks: 1,
361398
items_scanned: 1000,
362399
items_healed: 50,
363400
items_failed: 5,
@@ -371,6 +408,10 @@ mod tests {
371408
assert_eq!(output.heal_id, "heal-123");
372409
assert!(output.healing);
373410
assert_eq!(output.bucket, "test-bucket");
411+
assert_eq!(output.scan_mode, Some(HealScanMode::Deep));
412+
assert_eq!(output.scan_cycle, 42);
413+
assert_eq!(output.heal_queue_length, 3);
414+
assert_eq!(output.heal_active_tasks, 1);
374415
assert_eq!(output.items_scanned, 1000);
375416
assert_eq!(output.items_healed, 50);
376417
}
@@ -388,5 +429,10 @@ mod tests {
388429
started: Some("2024-01-01T10:00:00Z".to_string()),
389430
..Default::default()
390431
}));
432+
433+
assert!(has_heal_status_details(&HealStatus {
434+
heal_active_tasks: 1,
435+
..Default::default()
436+
}));
391437
}
392438
}

crates/core/src/admin/cluster.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,22 @@ pub struct HealStatus {
578578
#[serde(default)]
579579
pub object: String,
580580

581+
/// Current scan mode reported by background healing
582+
#[serde(default, skip_serializing_if = "Option::is_none")]
583+
pub scan_mode: Option<HealScanMode>,
584+
585+
/// Background heal scan cycle
586+
#[serde(default)]
587+
pub scan_cycle: u64,
588+
589+
/// Number of queued heal tasks
590+
#[serde(default)]
591+
pub heal_queue_length: u64,
592+
593+
/// Number of active heal tasks
594+
#[serde(default)]
595+
pub heal_active_tasks: u64,
596+
581597
/// Number of items scanned
582598
#[serde(default)]
583599
pub items_scanned: u64,
@@ -929,6 +945,10 @@ mod tests {
929945
let status = HealStatus::default();
930946
assert!(status.heal_id.is_empty());
931947
assert!(!status.healing);
948+
assert!(status.scan_mode.is_none());
949+
assert_eq!(status.scan_cycle, 0);
950+
assert_eq!(status.heal_queue_length, 0);
951+
assert_eq!(status.heal_active_tasks, 0);
932952
assert_eq!(status.items_scanned, 0);
933953
}
934954

crates/s3/src/admin.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,18 +392,43 @@ struct ServiceAccountInfo {
392392
struct BackgroundHealStatusResponse {
393393
#[serde(default)]
394394
bitrot_start_time: Option<String>,
395+
#[serde(default)]
396+
bitrot_start_cycle: u64,
397+
#[serde(default)]
398+
current_scan_mode: Option<u8>,
399+
#[serde(default)]
400+
heal_queue_length: u64,
401+
#[serde(default)]
402+
heal_active_tasks: u64,
395403
}
396404

397405
impl From<BackgroundHealStatusResponse> for HealStatus {
398406
fn from(response: BackgroundHealStatusResponse) -> Self {
407+
let scan_mode = background_heal_scan_mode(response.current_scan_mode);
408+
let legacy_healing = scan_mode.is_none() && response.bitrot_start_time.is_some();
399409
Self {
400-
healing: response.bitrot_start_time.is_some(),
410+
healing: matches!(scan_mode, Some(HealScanMode::Deep))
411+
|| response.heal_queue_length > 0
412+
|| response.heal_active_tasks > 0
413+
|| legacy_healing,
401414
started: response.bitrot_start_time,
415+
scan_mode,
416+
scan_cycle: response.bitrot_start_cycle,
417+
heal_queue_length: response.heal_queue_length,
418+
heal_active_tasks: response.heal_active_tasks,
402419
..Default::default()
403420
}
404421
}
405422
}
406423

424+
fn background_heal_scan_mode(scan_mode: Option<u8>) -> Option<HealScanMode> {
425+
match scan_mode {
426+
Some(1) => Some(HealScanMode::Normal),
427+
Some(2) => Some(HealScanMode::Deep),
428+
_ => None,
429+
}
430+
}
431+
407432
#[derive(Debug, Serialize)]
408433
struct RustfsHealOptions {
409434
recursive: bool,
@@ -1198,16 +1223,58 @@ mod tests {
11981223
fn test_background_heal_status_response_maps_to_heal_status() {
11991224
let status = HealStatus::from(BackgroundHealStatusResponse {
12001225
bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()),
1226+
bitrot_start_cycle: 42,
1227+
current_scan_mode: Some(2),
1228+
heal_queue_length: 3,
1229+
heal_active_tasks: 1,
12011230
});
12021231

12031232
assert!(status.healing);
12041233
assert_eq!(status.started.as_deref(), Some("2026-04-19T10:00:00Z"));
1234+
assert_eq!(status.scan_mode, Some(HealScanMode::Deep));
1235+
assert_eq!(status.scan_cycle, 42);
1236+
assert_eq!(status.heal_queue_length, 3);
1237+
assert_eq!(status.heal_active_tasks, 1);
12051238

12061239
let idle = HealStatus::from(BackgroundHealStatusResponse {
12071240
bitrot_start_time: None,
1241+
bitrot_start_cycle: 0,
1242+
current_scan_mode: Some(1),
1243+
heal_queue_length: 0,
1244+
heal_active_tasks: 0,
12081245
});
12091246
assert!(!idle.healing);
1247+
assert_eq!(idle.scan_mode, Some(HealScanMode::Normal));
12101248
assert!(idle.started.is_none());
1249+
1250+
let completed = HealStatus::from(BackgroundHealStatusResponse {
1251+
bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()),
1252+
bitrot_start_cycle: 42,
1253+
current_scan_mode: Some(1),
1254+
heal_queue_length: 0,
1255+
heal_active_tasks: 0,
1256+
});
1257+
assert!(!completed.healing);
1258+
assert_eq!(completed.scan_mode, Some(HealScanMode::Normal));
1259+
assert_eq!(completed.started.as_deref(), Some("2026-04-19T10:00:00Z"));
1260+
1261+
let legacy = HealStatus::from(BackgroundHealStatusResponse {
1262+
bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()),
1263+
bitrot_start_cycle: 0,
1264+
current_scan_mode: None,
1265+
heal_queue_length: 0,
1266+
heal_active_tasks: 0,
1267+
});
1268+
assert!(legacy.healing);
1269+
1270+
let active = HealStatus::from(BackgroundHealStatusResponse {
1271+
bitrot_start_time: None,
1272+
bitrot_start_cycle: 0,
1273+
current_scan_mode: None,
1274+
heal_queue_length: 0,
1275+
heal_active_tasks: 1,
1276+
});
1277+
assert!(active.healing);
12111278
}
12121279

12131280
#[test]
@@ -1222,14 +1289,20 @@ mod tests {
12221289

12231290
#[tokio::test]
12241291
async fn test_heal_status_uses_background_heal_status_endpoint() {
1225-
let (endpoint, receiver, handle) =
1226-
start_admin_test_server("200 OK", r#"{"bitrotStartTime":"2026-04-19T10:00:00Z"}"#);
1292+
let (endpoint, receiver, handle) = start_admin_test_server(
1293+
"200 OK",
1294+
r#"{"bitrotStartTime":"2026-04-19T10:00:00Z","bitrotStartCycle":42,"currentScanMode":2,"healQueueLength":3,"healActiveTasks":1}"#,
1295+
);
12271296
let client = admin_client_for_endpoint(&endpoint);
12281297

12291298
let status = client.heal_status().await.expect("heal status request");
12301299

12311300
assert!(status.healing);
12321301
assert_eq!(status.started.as_deref(), Some("2026-04-19T10:00:00Z"));
1302+
assert_eq!(status.scan_mode, Some(HealScanMode::Deep));
1303+
assert_eq!(status.scan_cycle, 42);
1304+
assert_eq!(status.heal_queue_length, 3);
1305+
assert_eq!(status.heal_active_tasks, 1);
12331306

12341307
let request = receiver.recv().expect("captured request");
12351308
assert_eq!(request.method, "POST");

schemas/output_v2.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,26 @@
270270
"type": "string",
271271
"description": "Object prefix being healed"
272272
},
273+
"scanMode": {
274+
"type": "string",
275+
"enum": [
276+
"normal",
277+
"deep"
278+
],
279+
"description": "Current background heal scan mode"
280+
},
281+
"scanCycle": {
282+
"type": "integer",
283+
"description": "Background heal scan cycle"
284+
},
285+
"healQueueLength": {
286+
"type": "integer",
287+
"description": "Queued heal tasks"
288+
},
289+
"healActiveTasks": {
290+
"type": "integer",
291+
"description": "Active heal tasks"
292+
},
273293
"itemsScanned": {
274294
"type": "integer",
275295
"description": "Items scanned"

0 commit comments

Comments
 (0)