mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 04:16:38 +00:00
fix(object-capacity): mark partial-failure refreshes degraded and merge over disk cache (#4499)
A full refresh with partial disk failures used to commit the surviving subset's sum as a fresh exact cluster total (no field carried the partial-failure fact), while the complete disk cache kept the failed disk's old value — so the reported capacity oscillated between the partial sum and the cache-merged total on alternating refreshes. - Add a degraded flag to CapacityUpdate/CachedCapacity, set when the scan behind the update had partial errors; expose it in refresh logs, admin capacity logs and a new rustfs_capacity_degraded_readings_total counter. - On a degraded full refresh, surface only the disks whose own scan fully succeeded and merge them over a complete disk cache, so failed disks keep their last-known values and the published total no longer dips and bounces back. The disk cache is never replaced from a degraded refresh. - Without a complete cache, keep the partial sum (unchanged #805 non-pollution behavior) but mark the reading degraded. Ref: rustfs/backlog#1014 (S06 from audit rustfs/backlog#1010)
This commit is contained in:
@@ -95,6 +95,26 @@ impl CapacityScanReport {
|
||||
update.expected_disk_count = Some(expected_disk_count);
|
||||
update.replaces_disk_cache = replaces_disk_cache;
|
||||
update.clear_dirty_disks = update.per_disk.iter().map(|entry| entry.disk.clone()).collect();
|
||||
} else if self.summary.had_partial_errors {
|
||||
// Partial failure: mark the reading degraded and surface only the
|
||||
// disks whose own scan fully succeeded, so the manager can merge
|
||||
// them over a complete disk cache while failed disks keep their
|
||||
// last-known values. A disk with intra-disk errors under-counts,
|
||||
// so its stale cache entry is closer to the truth than its scan.
|
||||
// The cache is never replaced from a degraded refresh.
|
||||
update.degraded = true;
|
||||
update.per_disk = self
|
||||
.per_disk
|
||||
.into_iter()
|
||||
.filter(|entry| !entry.scan.had_partial_errors)
|
||||
.map(|entry| DiskCapacityUpdate {
|
||||
disk: entry.disk,
|
||||
used_bytes: entry.scan.used_bytes,
|
||||
file_count: entry.scan.file_count,
|
||||
is_estimated: entry.scan.is_estimated,
|
||||
})
|
||||
.collect();
|
||||
update.clear_dirty_disks = update.per_disk.iter().map(|entry| entry.disk.clone()).collect();
|
||||
}
|
||||
|
||||
update
|
||||
@@ -1122,6 +1142,88 @@ mod tests {
|
||||
assert!(result.had_partial_errors);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_partial_scan_report_produces_degraded_update() {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut file = File::create(temp_dir.path().join("test.txt")).unwrap();
|
||||
file.write_all(b"Hello, World!").unwrap();
|
||||
drop(file);
|
||||
|
||||
let good_disk = CapacityDiskRef {
|
||||
endpoint: "disk-1".to_string(),
|
||||
drive_path: temp_dir.path().to_string_lossy().into_owned(),
|
||||
};
|
||||
let disks = vec![
|
||||
good_disk.clone(),
|
||||
CapacityDiskRef {
|
||||
endpoint: "disk-2".to_string(),
|
||||
drive_path: "/nonexistent/path".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let report = calculate_data_dir_used_capacity_report(&disks).await.unwrap();
|
||||
let update = report.into_capacity_update(disks.len(), true);
|
||||
|
||||
// A partial full refresh is degraded and must never replace the disk
|
||||
// cache; only the surviving disk is surfaced for cache merging.
|
||||
assert!(update.degraded);
|
||||
assert!(!update.replaces_disk_cache);
|
||||
assert_eq!(update.expected_disk_count, None);
|
||||
assert_eq!(update.per_disk.len(), 1);
|
||||
assert_eq!(update.per_disk[0].disk, disk_scope_key(&good_disk));
|
||||
assert_eq!(update.clear_dirty_disks, vec![disk_scope_key(&good_disk)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_capacity_update_partial_failure_filters_intra_disk_errors() {
|
||||
let clean = DiskCapacityScanResult {
|
||||
disk: CapacityScopeDisk {
|
||||
endpoint: "node-a".to_string(),
|
||||
drive_path: "/tmp/disk-a".to_string(),
|
||||
},
|
||||
scan: CapacityScanResult {
|
||||
used_bytes: 100,
|
||||
file_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let partial = DiskCapacityScanResult {
|
||||
disk: CapacityScopeDisk {
|
||||
endpoint: "node-b".to_string(),
|
||||
drive_path: "/tmp/disk-b".to_string(),
|
||||
},
|
||||
scan: CapacityScanResult {
|
||||
used_bytes: 40,
|
||||
file_count: 1,
|
||||
had_partial_errors: true,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let report = CapacityScanReport {
|
||||
summary: CapacityScanResult {
|
||||
used_bytes: 140,
|
||||
file_count: 2,
|
||||
had_partial_errors: true,
|
||||
..Default::default()
|
||||
},
|
||||
per_disk: vec![clean.clone(), partial],
|
||||
};
|
||||
|
||||
let update = report.into_capacity_update(3, true);
|
||||
|
||||
// A disk whose own scan had intra-disk errors under-counts, so it must
|
||||
// not overwrite its last-known cached value.
|
||||
assert!(update.degraded);
|
||||
assert_eq!(update.per_disk.len(), 1);
|
||||
assert_eq!(update.per_disk[0].disk, clean.disk);
|
||||
assert_eq!(update.per_disk[0].used_bytes, 100);
|
||||
assert!(!update.replaces_disk_cache);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_select_capacity_refresh_disks_returns_full_when_disk_cache_incomplete() {
|
||||
let manager = create_isolated_manager(HybridStrategyConfig::default());
|
||||
@@ -1159,6 +1261,7 @@ mod tests {
|
||||
total_used: 300,
|
||||
file_count: 3,
|
||||
is_estimated: false,
|
||||
degraded: false,
|
||||
per_disk: vec![
|
||||
DiskCapacityUpdate {
|
||||
disk: CapacityScopeDisk {
|
||||
|
||||
Reference in New Issue
Block a user